From 0ed2375fa78ab86c9087049ac5f12aaf1175fa91 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 16:17:25 +0800 Subject: [PATCH 001/116] Add Claude 3.5 Sonnet --- .../OpenAIService/APIs/ClaudeChatCompletionsService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 081795c4..53c33410 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -8,12 +8,14 @@ import Preferences /// https://docs.anthropic.com/claude/reference/messages_post public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { public enum KnownModel: String, CaseIterable { + case claude35Sonnet = "claude-3-5-sonnet-20240620" case claude3Opus = "claude-3-opus-20240229" case claude3Sonnet = "claude-3-sonnet-20240229" case claude3Haiku = "claude-3-haiku-20240307" public var contextWindow: Int { switch self { + case .claude35Sonnet: return 200_000 case .claude3Opus: return 200_000 case .claude3Sonnet: return 200_000 case .claude3Haiku: return 200_000 From 4b7882d6dd9abb7f9d53915bc0fed998c1357505 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 22:59:32 +0800 Subject: [PATCH 002/116] Fix that the Codeium suggestion service was not recognizable --- Tool/Sources/CodeiumService/CodeiumExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/CodeiumService/CodeiumExtension.swift b/Tool/Sources/CodeiumService/CodeiumExtension.swift index 93ab66da..b8aa0643 100644 --- a/Tool/Sources/CodeiumService/CodeiumExtension.swift +++ b/Tool/Sources/CodeiumService/CodeiumExtension.swift @@ -16,7 +16,7 @@ public final class CodeiumExtension: BuiltinExtension { public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .codeium } - public let suggestionService: CodeiumSuggestionService? + public let suggestionService: CodeiumSuggestionService public var chatTabTypes: [any ChatTab.Type] { [CodeiumChatTab.self] From c0360f908edd98c569f0cf518aaf136f85889401 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 22:59:48 +0800 Subject: [PATCH 003/116] Update Package.swift --- Core/Package.swift | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 337844ec..4bb63319 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -366,7 +366,6 @@ let package = Package( 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 @@ -376,27 +375,22 @@ extension [Target.Dependency] { extension [Package.Dependency] { var pro: [Package.Dependency] { if isProIncluded { - // include the pro package - return self + [.package(path: "../../CopilotForXcodePro/Pro")] + return self + [.package(path: "../../Pro")] } return self } } -let isProIncluded: Bool = { +var isProIncluded: Bool { func isProIncluded(file: StaticString = #file) -> Bool { let filePath = "\(file)" let fileURL = URL(fileURLWithPath: filePath) let rootURL = fileURL .deletingLastPathComponent() .deletingLastPathComponent() - .deletingLastPathComponent() let confURL = rootURL.appendingPathComponent("PLUS") - if !FileManager.default.fileExists(atPath: confURL.path) { - return false - } - return true + return FileManager.default.fileExists(atPath: confURL.path) } return isProIncluded() -}() +} From 448f6ffecadabc6984ec0f214edb7f714582dfdf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 23:03:17 +0800 Subject: [PATCH 004/116] Update build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index f264c7c1..4c9957e3 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.33.5 -APP_BUILD = 393 +APP_BUILD = 394 From de9d874b3036cb04b421d56efd16328b3eb6618c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 23:17:13 +0800 Subject: [PATCH 005/116] Add gemini models --- .../Preferences/Types/GoogleGenerativeChatModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift index 58f433c4..43e4af28 100644 --- a/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift +++ b/Tool/Sources/Preferences/Types/GoogleGenerativeChatModel.swift @@ -1,6 +1,8 @@ import Foundation public enum GoogleGenerativeAIModel: String { + case gemini15Pro = "gemini-1.5-pro" + case gemini15Flash = "gemini-1.5-flash" case geminiPro = "gemini-pro" } @@ -9,6 +11,10 @@ public extension GoogleGenerativeAIModel { switch self { case .geminiPro: return 32768 + case .gemini15Flash: + return 1_048_576 + case .gemini15Pro: + return 2_097_152 } } } From de2bb8aa46b85867e1aa0d6f7eaa00ab7961b04f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 8 Jul 2024 23:57:55 +0800 Subject: [PATCH 006/116] Update appcast.xml --- appcast.xml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/appcast.xml b/appcast.xml index d0e27a93..4b636dae 100644 --- a/appcast.xml +++ b/appcast.xml @@ -4,23 +4,22 @@ Copilot for Xcode 0.33.5 - Tue, 02 Jul 2024 23:07:42 +0800 - beta - 393 + Mon, 08 Jul 2024 23:53:53 +0800 + 394 0.33.5 12.0 - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.5.beta - + https://copilotforxcode.intii.com/changelog/0.33.5 + 0.33.5 - Mon, 01 Jul 2024 01:11:30 +0800 - 392 + Tue, 02 Jul 2024 23:07:42 +0800 beta + 393 0.33.5 12.0 https://github.com/intitni/CopilotForXcode/releases/tag/0.33.5.beta - + 0.33.4 @@ -40,15 +39,5 @@ https://github.com/intitni/CopilotForXcode/releases/tag/0.33.3 - - 0.33.1 - Sat, 25 May 2024 03:24:57 +0800 - https://github.com/intitni/CopilotForXcode/releases/tag/0.33.1.beta - beta - 380 - 0.33.1 - 12.0 - - \ No newline at end of file From 3ad0107712d832b5fe7f8928067f1eb24c75d753 Mon Sep 17 00:00:00 2001 From: RoshanNagaram-eng Date: Thu, 11 Jul 2024 15:36:37 -0700 Subject: [PATCH 007/116] Cleaned up codeium --- .../FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift | 2 +- Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift | 4 ++-- Tool/Sources/CodeiumService/Services/CodeiumService.swift | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift index 3484e34d..83041538 100644 --- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -67,7 +67,7 @@ struct ChatSettingsGeneralSectionView: View { case .browser: Text("Open web page in browser").tag(mode) case .codeiumChat: - Text("Open Codeium chat tab (beta)").tag(mode) + Text("Open Codeium chat tab").tag(mode) } } } diff --git a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift index d2dcb704..8a40da2b 100644 --- a/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift +++ b/Tool/Sources/CodeiumService/ChatTab/CodeiumChatTab.swift @@ -140,11 +140,11 @@ public class CodeiumChatTab: ChatTab { } public static func chatBuilders() -> [ChatTabBuilder] { - [Builder(title: "Codeium Chat (Beta)")] + [Builder(title: "Codeium Chat")] } public static func defaultChatBuilder() -> ChatTabBuilder { - Builder(title: "Codeium Chat (Beta)") + Builder(title: "Codeium Chat") } } diff --git a/Tool/Sources/CodeiumService/Services/CodeiumService.swift b/Tool/Sources/CodeiumService/Services/CodeiumService.swift index ba05bae0..3aa03540 100644 --- a/Tool/Sources/CodeiumService/Services/CodeiumService.swift +++ b/Tool/Sources/CodeiumService/Services/CodeiumService.swift @@ -351,6 +351,8 @@ extension CodeiumService: CodeiumSuggestionServiceType { URLQueryItem(name: "ide_version", value: metadata.ide_version), URLQueryItem(name: "web_server_url", value: webServerUrl), URLQueryItem(name: "ide_telemetry_enabled", value: "true"), + URLQueryItem(name: "has_enterprise_extension", value: String(UserDefaults.shared.value(for: \.codeiumEnterpriseMode))), + URLQueryItem(name: "has_index_service", value: String(UserDefaults.shared.value(for: \.codeiumIndexEnabled))) ] if let url = components.url { From b2bac0969f71f3af1edca187ac9572d2764faba0 Mon Sep 17 00:00:00 2001 From: RoshanNagaram-eng Date: Tue, 16 Jul 2024 11:14:00 -0700 Subject: [PATCH 008/116] updated lang server version --- .../LanguageServer/CodeiumInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index e2b25492..7f2c8866 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.8.8" + static let latestSupportedVersion = "1.8.83" public init() {} From 3587ef480ecbe13d8a19e9d734b6e7a3bb405be0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 11 Jul 2024 16:08:56 +0800 Subject: [PATCH 009/116] Add SuggestionServiceEventHandler --- .../SuggestionService/SuggestionService.swift | 17 +++++++++----- .../SuggestionServiceEventHandler.swift | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 2d6cc2da..95224935 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,7 +1,7 @@ import BuiltinExtension import CodeiumService -import struct CopilotForXcodeKit.WorkspaceInfo import enum CopilotForXcodeKit.SuggestionServiceError +import struct CopilotForXcodeKit.WorkspaceInfo import Foundation import GitHubCopilotService import Preferences @@ -17,18 +17,21 @@ import ProExtension public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { + typealias Middleware = SuggestionServiceMiddleware + typealias EventHandler = SuggestionServiceEventHandler public var configuration: SuggestionProvider.SuggestionServiceConfiguration { get async { await suggestionProvider.configuration } } - let middlewares: [SuggestionServiceMiddleware] + let middlewares: [Middleware] + let eventHandlers: [EventHandler] let suggestionProvider: SuggestionServiceProvider public init( provider: any SuggestionServiceProvider, - middlewares: [SuggestionServiceMiddleware] = SuggestionServiceMiddlewareContainer - .middlewares + middlewares: [Middleware] = SuggestionServiceMiddlewareContainer.middlewares, + eventHandlers: [EventHandler] = SuggestionServiceEventHandlerContainer.handlers ) { suggestionProvider = provider self.middlewares = middlewares @@ -67,7 +70,7 @@ public extension SuggestionService { do { var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:) let configuration = await configuration - + for middleware in middlewares.reversed() { getSuggestion = { [getSuggestion] request, workspaceInfo in try await middleware.getSuggestion( @@ -79,7 +82,7 @@ public extension SuggestionService { ) } } - + return try await getSuggestion(request, workspaceInfo) } catch let error as SuggestionServiceError { throw error @@ -92,6 +95,7 @@ public extension SuggestionService { _ suggestion: SuggestionBasic.CodeSuggestion, workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { + eventHandlers.forEach { $0.didAccept(suggestion, workspaceInfo: workspaceInfo) } await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo) } @@ -99,6 +103,7 @@ public extension SuggestionService { _ suggestions: [SuggestionBasic.CodeSuggestion], workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { + eventHandlers.forEach { $0.didReject(suggestion, workspaceInfo: workspaceInfo) } await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo) } diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift new file mode 100644 index 00000000..1b7f3dc3 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift @@ -0,0 +1,22 @@ +import Foundation +import SuggestionBasic +import CopilotForXcodeKit + +public protocol SuggestionServiceEventHandler { + func didAccept(_ suggestion: CodeSuggestion, workspaceInfo: WorkspaceInfo) + func didReject(_ suggestion: CodeSuggestion, workspaceInfo: WorkspaceInfo) +} + +public enum SuggestionServiceEventHandlerContainer { + static var builtinHandlers: [SuggestionServiceEventHandler] = [] + + static var customHandlers: [SuggestionServiceEventHandler] = [] + + public static var handlers: [SuggestionServiceEventHandler] { + builtinHandlers + customHandlers + } + + public static func addHandler(_ handler: SuggestionServiceEventHandler) { + customHandlers.append(handler) + } +} From 40d3ac238543577dc72883c862318276ffef3acd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 11 Jul 2024 22:18:02 +0800 Subject: [PATCH 010/116] Add SuggestionServiceEventHandler --- .../Sources/SuggestionService/SuggestionService.swift | 4 ++-- Tool/Sources/SuggestionBasic/CodeSuggestion.swift | 9 +++++++-- .../PostProcessingSuggestionServiceMiddleware.swift | 6 +----- .../Sources/SuggestionProvider/String+Extension.swift | 11 +++++++++++ .../SuggestionServiceEventHandler.swift | 11 ++++++++--- .../SuggestionServiceMiddleware.swift | 4 ++++ 6 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 Tool/Sources/SuggestionProvider/String+Extension.swift diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 95224935..449ee87e 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -17,8 +17,8 @@ import ProExtension public protocol SuggestionServiceType: SuggestionServiceProvider {} public actor SuggestionService: SuggestionServiceType { - typealias Middleware = SuggestionServiceMiddleware - typealias EventHandler = SuggestionServiceEventHandler + public typealias Middleware = SuggestionServiceMiddleware + public typealias EventHandler = SuggestionServiceEventHandler public var configuration: SuggestionProvider.SuggestionServiceConfiguration { get async { await suggestionProvider.configuration } } diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index bd124fc1..32adab0b 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -6,13 +6,16 @@ public struct CodeSuggestion: Codable, Equatable { id: String, text: String, position: CursorPosition, - range: CursorRange + range: CursorRange, + middlewareComments: [String] = [], + metadata: [String: String] = [:] ) { self.text = text self.position = position self.id = id self.range = range - middlewareComments = [] + self.middlewareComments = middlewareComments + self.metadata = metadata } public static func == (lhs: CodeSuggestion, rhs: CodeSuggestion) -> Bool { @@ -32,5 +35,7 @@ public struct CodeSuggestion: Codable, Equatable { public var range: CursorRange /// A place to store comments inserted by middleware for debugging use. @FallbackDecoding public var middlewareComments: [String] + /// A place to store extra data. + @FallbackDecoding public var metadata: [String: String] } diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index e69e29d2..8f5992b8 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -21,11 +21,7 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle } static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { - var text = suggestion.text[...] - while let last = text.last, last.isNewline || last.isWhitespace { - text = text.dropLast(1) - } - suggestion.text = String(text) + suggestion.text = suggestion.text.removedTrailingWhitespacesAndNewlines() } static func checkIfSuggestionHasNoEffect( diff --git a/Tool/Sources/SuggestionProvider/String+Extension.swift b/Tool/Sources/SuggestionProvider/String+Extension.swift new file mode 100644 index 00000000..d177e0b0 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/String+Extension.swift @@ -0,0 +1,11 @@ +import Foundation + +public extension String { + func removedTrailingWhitespacesAndNewlines() -> String { + var text = self[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + return String(text) + } +} diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift index 1b7f3dc3..e89fe938 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift @@ -1,10 +1,10 @@ +import CopilotForXcodeKit import Foundation import SuggestionBasic -import CopilotForXcodeKit public protocol SuggestionServiceEventHandler { - func didAccept(_ suggestion: CodeSuggestion, workspaceInfo: WorkspaceInfo) - func didReject(_ suggestion: CodeSuggestion, workspaceInfo: WorkspaceInfo) + func didAccept(_ suggestion: SuggestionBasic.CodeSuggestion, workspaceInfo: WorkspaceInfo) + func didReject(_ suggestions: [SuggestionBasic.CodeSuggestion], workspaceInfo: WorkspaceInfo) } public enum SuggestionServiceEventHandlerContainer { @@ -19,4 +19,9 @@ public enum SuggestionServiceEventHandlerContainer { public static func addHandler(_ handler: SuggestionServiceEventHandler) { customHandlers.append(handler) } + + public static func addHandlers(_ handlers: [SuggestionServiceEventHandler]) { + customHandlers.append(contentsOf: handlers) + } } + diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index dcbfba5e..9d7f6af6 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -27,6 +27,10 @@ public enum SuggestionServiceMiddlewareContainer { public static func addMiddleware(_ middleware: SuggestionServiceMiddleware) { customMiddlewares.append(middleware) } + + public static func addMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) { + customMiddlewares.append(contentsOf: middlewares) + } } public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMiddleware { From 0e889cf21144e57bbd0955052a346c90df6ec772 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 12 Jul 2024 16:12:56 +0800 Subject: [PATCH 011/116] Add command handler --- Core/Package.swift | 2 + .../GraphicalUserInterfaceController.swift | 6 +- Core/Sources/Service/Service.swift | 2 + .../PseudoCommandHandler.swift | 32 ++++++++- Tool/Package.swift | 10 +++ .../CommandHandler/CommandHandler.swift | 65 +++++++++++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 Tool/Sources/CommandHandler/CommandHandler.swift diff --git a/Core/Package.swift b/Core/Package.swift index 4bb63319..b65d2959 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -90,6 +90,7 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), + .product(name: "CommandHandler", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -388,6 +389,7 @@ var isProIncluded: Bool { let rootURL = fileURL .deletingLastPathComponent() .deletingLastPathComponent() + .deletingLastPathComponent() let confURL = rootURL.appendingPathComponent("PLUS") return FileManager.default.fileExists(atPath: confURL.path) } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 396145f9..849588f4 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -186,7 +186,7 @@ struct GUI { ) } } - + case let .sendCustomCommandToActiveChat(command): @Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async { if tab.service.isReceivingMessage { @@ -218,9 +218,7 @@ struct GUI { return .run { send in guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) - else { - return - } + else { return } await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) await send(.openChatPanel(forceDetach: false)) if let chatTab = chatTab as? ChatGPTChatTab { diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 35ca6a50..8c8706bf 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,6 +1,7 @@ import BuiltinExtension import CodeiumService import Combine +import CommandHandler import Dependencies import Foundation import GitHubCopilotService @@ -45,6 +46,7 @@ public final class Service { private init() { @Dependency(\.workspacePool) var workspacePool + CommandHandlerDependencyKey.liveValue = PseudoCommandHandler() BuiltinExtensionManager.shared.setupExtensions([ GitHubCopilotExtension(workspacePool: workspacePool), diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 6afc5956..b1e657a2 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,13 +1,14 @@ import ActiveApplicationMonitor import AppKit import CodeiumService +import CommandHandler import enum CopilotForXcodeKit.SuggestionServiceError import Dependencies import Logger import PlusFeatureFlag import Preferences -import SuggestionInjector import SuggestionBasic +import SuggestionInjector import Toast import Workspace import WorkspaceSuggestionService @@ -21,7 +22,7 @@ import BrowserChatTab /// It's used to run some commands without really triggering the menu bar item. /// /// For example, we can use it to generate real-time suggestions without Apple Scripts. -struct PseudoCommandHandler { +struct PseudoCommandHandler: CommandHandler { static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) private var toast: ToastController { ToastControllerDependencyKey.liveValue } @@ -397,6 +398,33 @@ struct PseudoCommandHandler { } } } + + func sendChatMessage(_ message: String) async { + let store = Service.shared.guiController.store + await store.send(.sendCustomCommandToActiveChat(CustomCommand( + commandId: "", + name: "", + feature: .chatWithSelection( + extraSystemPrompt: nil, + prompt: message, + useExtraSystemPrompt: nil + ) + ))).finish() + } + + @WorkspaceActor + func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async { + guard let filespace = await getFilespace() else { return } + filespace.setSuggestions(suggestions) + PresentInWindowSuggestionPresenter().presentSuggestion(fileURL: filespace.fileURL) + } + + func toast(_ message: String, as type: ToastType) { + Task { @MainActor in + let store = Service.shared.guiController.store + store.send(.suggestionWidget(.toastPanel(.toast(.toast(message, type, nil))))) + } + } } extension PseudoCommandHandler { diff --git a/Tool/Package.swift b/Tool/Package.swift index 1bb87347..6bd73b23 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -46,6 +46,7 @@ let package = Package( .library(name: "DebounceFunction", targets: ["DebounceFunction"]), .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), + .library(name: "CommandHandler", targets: ["CommandHandler"]), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. @@ -292,6 +293,15 @@ let package = Package( ), ] ), + + .target( + name: "CommandHandler", + dependencies: [ + "XcodeInspector", + "Preferences", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), // MARK: - Services diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift new file mode 100644 index 00000000..bcdc162d --- /dev/null +++ b/Tool/Sources/CommandHandler/CommandHandler.swift @@ -0,0 +1,65 @@ +import Dependencies +import Foundation +import Preferences +import SuggestionBasic +import Toast +import XcodeInspector + +/// Provides an interface to handle commands. +public protocol CommandHandler { + // MARK: Suggestion + + func presentSuggestions(_ suggestions: [CodeSuggestion]) async + func presentPreviousSuggestion() async + func presentNextSuggestion() async + func rejectSuggestions() async + func acceptSuggestion() async + func dismissSuggestion() async + func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async + + // MARK: Chat + + func openChat(forceDetach: Bool) + func sendChatMessage(_ message: String) async + + // MARK: Prompt to Code + + func acceptPromptToCode() async + + // MARK: Custom Command + + func handleCustomCommand(_ command: CustomCommand) async + + // MARK: Toast + + func toast(_ string: String, as type: ToastType) +} + +public struct CommandHandlerDependencyKey: DependencyKey { + public static var liveValue: CommandHandler = NoopCommandHandler() +} + +public extension DependencyValues { + var commandHandler: CommandHandler { + get { self[CommandHandlerDependencyKey.self] } + set { self[CommandHandlerDependencyKey.self] = newValue } + } +} + +struct NoopCommandHandler: CommandHandler { + static let shared: CommandHandler = NoopCommandHandler() + + func presentSuggestions(_: [CodeSuggestion]) async {} + func presentPreviousSuggestion() async {} + func presentNextSuggestion() async {} + func rejectSuggestions() async {} + func acceptSuggestion() async {} + func dismissSuggestion() async {} + func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {} + func openChat(forceDetach: Bool) {} + func sendChatMessage(_: String) async {} + func acceptPromptToCode() async {} + func handleCustomCommand(_: CustomCommand) async {} + func toast(_: String, as: ToastType) {} +} + From 95b99283e1e319bf4396010f213d60de8bedfcd6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 12 Jul 2024 16:13:11 +0800 Subject: [PATCH 012/116] Initialize all properties --- Core/Sources/SuggestionService/SuggestionService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 449ee87e..335f0c83 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -35,6 +35,7 @@ public actor SuggestionService: SuggestionServiceType { ) { suggestionProvider = provider self.middlewares = middlewares + self.eventHandlers = eventHandlers } public static func service( @@ -103,7 +104,7 @@ public extension SuggestionService { _ suggestions: [SuggestionBasic.CodeSuggestion], workspaceInfo: CopilotForXcodeKit.WorkspaceInfo ) async { - eventHandlers.forEach { $0.didReject(suggestion, workspaceInfo: workspaceInfo) } + eventHandlers.forEach { $0.didReject(suggestions, workspaceInfo: workspaceInfo) } await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo) } From 638b6d588760d37a526e84cbf4ea8ec34ad24c5b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 12 Jul 2024 16:13:29 +0800 Subject: [PATCH 013/116] Adjust the property workspacePool --- Core/Sources/Service/Service.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 8c8706bf..bcd9be1f 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -27,8 +27,7 @@ import ProService public final class Service { public static let shared = Service() - @WorkspaceActor - let workspacePool: WorkspacePool + @Dependency(\.workspacePool) var workspacePool @MainActor public let guiController = GraphicalUserInterfaceController() public let realtimeSuggestionController = RealtimeSuggestionController() @@ -65,7 +64,7 @@ public final class Service { workspacePool.registerPlugin { BuiltinExtensionWorkspacePlugin(workspace: $0) } - self.workspacePool = workspacePool + globalShortcutManager = .init(guiController: guiController) keyBindingManager = .init( workspacePool: workspacePool, From 2dfe71d99aa3e180e81153114bd08061bfc30df6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 13 Jul 2024 23:11:06 +0800 Subject: [PATCH 014/116] Fix workspacePool initialization --- Core/Sources/Service/Service.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index bcd9be1f..5cec4a8d 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -27,7 +27,7 @@ import ProService public final class Service { public static let shared = Service() - @Dependency(\.workspacePool) var workspacePool + let workspacePool = WorkspacePool() @MainActor public let guiController = GraphicalUserInterfaceController() public let realtimeSuggestionController = RealtimeSuggestionController() @@ -44,7 +44,7 @@ public final class Service { var cancellable = Set() private init() { - @Dependency(\.workspacePool) var workspacePool + WorkspacePoolDependencyKey.liveValue = workspacePool CommandHandlerDependencyKey.liveValue = PseudoCommandHandler() BuiltinExtensionManager.shared.setupExtensions([ From 4ea2564c552511c87f7d457e6862ba2d774fbd3e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 13 Jul 2024 23:11:27 +0800 Subject: [PATCH 015/116] Adjust implementation of filespace validate suggestion --- .../Filespace+SuggestionService.swift | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index ec35158f..f887eda4 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -42,23 +42,35 @@ public extension Filespace { @WorkspaceActor func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { guard let presentingSuggestion else { return false } - - // cursor has moved to another line - if cursorPosition.line != presentingSuggestion.position.line { + guard Self.validateSuggestion( + presentingSuggestion, + lines: lines, + cursorPosition: cursorPosition + ) + else { reset() resetSnapshot() return false } + return true + } +} + +extension Filespace { + static func validateSuggestion( + _ suggestion: CodeSuggestion, + lines: [String], + cursorPosition: CursorPosition + ) -> Bool { + // cursor has moved to another line + if cursorPosition.line != suggestion.position.line { return false } + // the cursor position is valid - guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { - reset() - resetSnapshot() - return false - } + guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { return false } let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending - let suggestionLines = presentingSuggestion.text.split(whereSeparator: \.isNewline) + let suggestionLines = suggestion.text.split(whereSeparator: \.isNewline) let suggestionFirstLine = suggestionLines.first ?? "" /// For example: @@ -78,7 +90,7 @@ public extension Filespace { /// ``` let typedSuggestion = { assert( - presentingSuggestion.range.start.character >= 0, + suggestion.range.start.character >= 0, "Generating suggestion with invalid range" ) @@ -86,7 +98,7 @@ public extension Filespace { let startIndex = utf16View.index( utf16View.startIndex, - offsetBy: max(0, presentingSuggestion.range.start.character), + offsetBy: max(0, suggestion.range.start.character), limitedBy: utf16View.endIndex ) ?? utf16View.startIndex @@ -103,14 +115,12 @@ public extension Filespace { return "" }() - /// if the line will not change after accepting the suggestion + // if the line will not change after accepting the suggestion if suggestionLines.count == 1 { if editingLine.hasPrefix(suggestionFirstLine), cursorPosition.character - >= suggestionFirstLine.utf16.count + presentingSuggestion.range.start.character + >= suggestionFirstLine.utf16.count + suggestion.range.start.character { - reset() - resetSnapshot() return false } } @@ -119,22 +129,16 @@ public extension Filespace { if cursorPosition.character > 0, !suggestionFirstLine.hasPrefix(typedSuggestion) { - reset() - resetSnapshot() return false } // finished typing the whole suggestion when the suggestion has only one line if typedSuggestion.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 { - reset() - resetSnapshot() return false } // undo to a state before the suggestion was generated - if editingLine.utf16.count < presentingSuggestion.position.character { - reset() - resetSnapshot() + if editingLine.utf16.count < suggestion.position.character { return false } From 90b297760be7962ebe8db103b6b337092d02461c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 13 Jul 2024 23:42:14 +0800 Subject: [PATCH 016/116] Move SuggestionInjector to Tool --- Core/Package.swift | 10 ---------- TestPlan.xctestplan | 14 +++++++------- Tool/Package.swift | 12 +++++++++++- .../SuggestionInjector/SuggestionInjector.swift | 0 .../AcceptSuggestionTests.swift | 0 .../ProposeSuggestionTests.swift | 0 .../RejectSuggestionTests.swift | 0 7 files changed, 18 insertions(+), 18 deletions(-) rename {Core => Tool}/Sources/SuggestionInjector/SuggestionInjector.swift (100%) rename {Core => Tool}/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift (100%) rename {Core => Tool}/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift (100%) rename {Core => Tool}/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift (100%) diff --git a/Core/Package.swift b/Core/Package.swift index b65d2959..e8778e42 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -14,7 +14,6 @@ let package = Package( name: "Service", targets: [ "Service", - "SuggestionInjector", "FileChangeChecker", "LaunchAgentManager", "UpdateChecker", @@ -104,7 +103,6 @@ let package = Package( dependencies: [ "Service", "Client", - "SuggestionInjector", .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), @@ -147,14 +145,6 @@ let package = Package( "ProExtension", ]) ), - .target( - name: "SuggestionInjector", - dependencies: [.product(name: "SuggestionBasic", package: "Tool")] - ), - .testTarget( - name: "SuggestionInjectorTests", - dependencies: ["SuggestionInjector"] - ), // MARK: - Prompt To Code diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 1d83d1c6..679b7891 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -29,13 +29,6 @@ "name" : "ServiceTests" } }, - { - "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" - } - }, { "target" : { "containerPath" : "container:Core", @@ -154,6 +147,13 @@ "identifier" : "SuggestionBasicTests", "name" : "SuggestionBasicTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 6bd73b23..1e45ef89 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -20,7 +20,7 @@ let package = Package( name: "ChatContextCollector", targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"] ), - .library(name: "SuggestionBasic", targets: ["SuggestionBasic"]), + .library(name: "SuggestionBasic", targets: ["SuggestionBasic", "SuggestionInjector"]), .library(name: "ASTParser", targets: ["ASTParser"]), .library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]), .library(name: "Toast", targets: ["Toast"]), @@ -159,6 +159,15 @@ let package = Package( .product(name: "CodableWrappers", package: "CodableWrappers"), ] ), + + .target( + name: "SuggestionInjector", + dependencies: ["SuggestionBasic"] + ), + .testTarget( + name: "SuggestionInjectorTests", + dependencies: ["SuggestionInjector"] + ), .target( name: "AIModel", @@ -264,6 +273,7 @@ let package = Package( "SuggestionProvider", "XPCShared", "BuiltinExtension", + "SuggestionInjector", ] ), diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift similarity index 100% rename from Core/Sources/SuggestionInjector/SuggestionInjector.swift rename to Tool/Sources/SuggestionInjector/SuggestionInjector.swift diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift similarity index 100% rename from Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift rename to Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift diff --git a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift similarity index 100% rename from Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift rename to Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift diff --git a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift similarity index 100% rename from Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift rename to Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift From ed2e4f49a14c5f6b950231753dc5bdc1c099e82d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jul 2024 02:13:21 +0800 Subject: [PATCH 017/116] Adjust suggestion validation to allow suggestions to rewrite the lines --- .../PseudoCommandHandler.swift | 2 +- ...FilespaceSuggestionInvalidationTests.swift | 121 ++++++++++--- .../Filespace+SuggestionService.swift | 160 ++++++++++-------- .../Workspace+SuggestionService.swift | 2 +- 4 files changed, 187 insertions(+), 98 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index b1e657a2..3ff8df31 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -87,7 +87,7 @@ struct PseudoCommandHandler: CommandHandler { } let snapshot = FilespaceSuggestionSnapshot( - linesHash: editor.lines.hashValue, + lines: editor.lines, cursorPosition: editor.cursorPosition ) diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index 29a71e69..fe8d0a1f 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -8,6 +8,7 @@ import XCTest class FilespaceSuggestionInvalidationTests: XCTestCase { @WorkspaceActor func prepare( + lines: [String], suggestionText: String, cursorPosition: CursorPosition, range: CursorRange @@ -23,17 +24,20 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { range: range ), ] + filespace.suggestionSourceSnapshot = .init(lines: lines, cursorPosition: cursorPosition) return filespace } func test_text_typing_suggestion_should_be_valid() async throws { + let lines = ["\n", "hell\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hell\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 4) ) XCTAssertTrue(isValid) @@ -42,60 +46,69 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "hell man\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hell man\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 4) ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - + func test_text_typing_suggestion_with_emoji_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "hellπŸŽ†πŸŽ† man\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "helloπŸŽ†πŸŽ† man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hellπŸŽ†πŸŽ† man\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 4) ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - + func test_text_typing_suggestion_typed_emoji_in_the_middle_should_be_valid() async throws { + let lines = ["\n", "hπŸŽ†πŸŽ†o ma\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hπŸŽ†πŸŽ†o man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hπŸŽ†πŸŽ†o ma\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 2) ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - + func test_text_typing_suggestion_cutting_emoji_in_the_middle_should_be_valid() async throws { // undefined behavior, must not crash - + + let lines = ["\n", "hπŸŽ†πŸŽ†o ma\n", "\n"] + let filespace = try await prepare( + lines: lines, suggestionText: "hπŸŽ†πŸŽ†o man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hπŸŽ†πŸŽ†o ma\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 3) ) XCTAssertTrue(isValid) @@ -104,13 +117,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } func test_text_cursor_moved_to_another_line_should_invalidate() async throws { + let lines = ["\n", "hell\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hell\n", "\n"], + lines: lines, cursorPosition: .init(line: 2, character: 0) ) XCTAssertFalse(isValid) @@ -119,13 +134,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } func test_text_cursor_is_invalid_should_invalidate() async throws { + let lines = ["\n", "hell\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 100, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hell\n", "\n"], + lines: lines, cursorPosition: .init(line: 100, character: 4) ) XCTAssertFalse(isValid) @@ -135,9 +152,10 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { func test_line_content_does_not_match_input_should_invalidate() async throws { let filespace = try await prepare( + lines: ["\n", "hello\n", "\n"], suggestionText: "hello man", - cursorPosition: .init(line: 1, character: 0), - range: .init(startPair: (1, 0), endPair: (1, 0)) + cursorPosition: .init(line: 1, character: 5), + range: .init(startPair: (1, 0), endPair: (1, 5)) ) let isValid = await filespace.validateSuggestions( lines: ["\n", "helo\n", "\n"], @@ -150,9 +168,10 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws { let filespace = try await prepare( + lines: ["\n", "hello\n", "\n"], suggestionText: "hello man", - cursorPosition: .init(line: 1, character: 0), - range: .init(startPair: (1, 0), endPair: (1, 0)) + cursorPosition: .init(line: 1, character: 5), + range: .init(startPair: (1, 0), endPair: (1, 5)) ) let isValid = await filespace.validateSuggestions( lines: ["\n", "helo\n", "\n"], @@ -164,13 +183,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws { + let lines = ["\n", "hello ma\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", - cursorPosition: .init(line: 1, character: 0), - range: .init(startPair: (1, 0), endPair: (1, 0)) + cursorPosition: .init(line: 1, character: 8), + range: .init(startPair: (1, 0), endPair: (1, 8)) ) let wasValid = await filespace.validateSuggestions( - lines: ["\n", "hello ma\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 8) ) let isValid = await filespace.validateSuggestions( @@ -182,15 +203,18 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let suggestion = filespace.presentingSuggestion XCTAssertNil(suggestion) } - - func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate() async throws { + + func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate( + ) async throws { + let lines = ["\n", "hello mπŸŽ†πŸŽ†a\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello mπŸŽ†πŸŽ†an", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let wasValid = await filespace.validateSuggestions( - lines: ["\n", "hello mπŸŽ†πŸŽ†a\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 12) ) let isValid = await filespace.validateSuggestions( @@ -205,13 +229,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate( ) async throws { + let lines = ["\n", "hello ma!!!!\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let wasValid = await filespace.validateSuggestions( - lines: ["\n", "hello ma!!!!\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 8) ) let isValid = await filespace.validateSuggestions( @@ -225,28 +251,33 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { } func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws { + let lines = ["\n", "hello man\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man\nhow are you?", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hello man\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 9) ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNotNil(suggestion) } - - func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid() async throws { + + func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid( + ) async throws { + let lines = ["\n", "hello mπŸŽ†πŸŽ†an\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello mπŸŽ†πŸŽ†an\nhow are you?", cursorPosition: .init(line: 1, character: 0), range: .init(startPair: (1, 0), endPair: (1, 0)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hello mπŸŽ†πŸŽ†an\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 13) ) XCTAssertTrue(isValid) @@ -256,18 +287,54 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate( ) async throws { + let lines = ["\n", "hell\n", "\n"] let filespace = try await prepare( + lines: lines, suggestionText: "hello man", cursorPosition: .init(line: 1, character: 5), // generating man from hello range: .init(startPair: (1, 0), endPair: (1, 5)) ) let isValid = await filespace.validateSuggestions( - lines: ["\n", "hell\n", "\n"], + lines: lines, cursorPosition: .init(line: 1, character: 4) ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNil(suggestion) } + + func test_rewriting_the_current_line_by_removing_the_suffix_should_be_valid() async throws { + let lines = ["hello world !!!\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello world", + cursorPosition: .init(line: 0, character: 15), + range: .init(startPair: (0, 0), endPair: (0, 15)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 0, character: 15) + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } + + func test_rewriting_the_current_line_should_be_valid() async throws { + let lines = ["hello everyone !!!\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello world !!!", + cursorPosition: .init(line: 0, character: 15), + range: .init(startPair: (0, 0), endPair: (0, 15)) + ) + let isValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 0, character: 18) + ) + XCTAssertTrue(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNotNil(suggestion) + } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index f887eda4..1ccbcf02 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -1,24 +1,52 @@ import Foundation import SuggestionBasic +import SuggestionInjector import Workspace +/// The moment when a suggestion is generated. public struct FilespaceSuggestionSnapshot: Equatable { - #warning("TODO: Can we remove it?") - public var linesHash: Int + public var editingLine: String public var cursorPosition: CursorPosition + public var editingLinePrefix: String + public var editingLineSuffix: String - public init(linesHash: Int, cursorPosition: CursorPosition) { - self.linesHash = linesHash + public static func == ( + lhs: FilespaceSuggestionSnapshot, + rhs: FilespaceSuggestionSnapshot + ) -> Bool { + lhs.editingLine == rhs.editingLine + && lhs.cursorPosition == rhs.cursorPosition + } + + public init(lines: [String], cursorPosition: CursorPosition) { self.cursorPosition = cursorPosition + editingLine = if cursorPosition.line >= 0 && cursorPosition.line < lines.count { + lines[cursorPosition.line] + } else { + "" + } + let col = cursorPosition.character + let view = editingLine.utf16 + editingLinePrefix = if col >= 0 { + String(view.prefix(col)) ?? "" + } else { + "" + } + editingLineSuffix = if col >= 0, col < editingLine.utf16.count { + String(view[view.index(view.startIndex, offsetBy: col)...]) ?? "" + } else { + "" + } } } public struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey { public static func createDefaultValue() - -> FilespaceSuggestionSnapshot { .init(linesHash: -1, cursorPosition: .outOfScope) } + -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) } } public extension FilespacePropertyValues { + /// The state of the file when a suggestion is generated. @WorkspaceActor var suggestionSourceSnapshot: FilespaceSuggestionSnapshot { get { self[FilespaceSuggestionSnapshotKey.self] } @@ -29,9 +57,8 @@ public extension FilespacePropertyValues { public extension Filespace { @WorkspaceActor func resetSnapshot() { - // swiftformat:disable redundantSelf - self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() - // swiftformat:enable all + self[keyPath: \.suggestionSourceSnapshot] = FilespaceSuggestionSnapshotKey + .createDefaultValue() } /// Validate the suggestion is still valid. @@ -42,12 +69,15 @@ public extension Filespace { @WorkspaceActor func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { guard let presentingSuggestion else { return false } + let snapshot = self[keyPath: \.suggestionSourceSnapshot] + if snapshot.cursorPosition == .outOfScope { return false } + guard Self.validateSuggestion( presentingSuggestion, + snapshot: snapshot, lines: lines, cursorPosition: cursorPosition - ) - else { + ) else { reset() resetSnapshot() return false @@ -60,6 +90,7 @@ public extension Filespace { extension Filespace { static func validateSuggestion( _ suggestion: CodeSuggestion, + snapshot: FilespaceSuggestionSnapshot, lines: [String], cursorPosition: CursorPosition ) -> Bool { @@ -71,78 +102,69 @@ extension Filespace { let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending let suggestionLines = suggestion.text.split(whereSeparator: \.isNewline) - let suggestionFirstLine = suggestionLines.first ?? "" - - /// For example: - /// ``` - /// ABCD012 // typed text - /// ^ - /// 0123456 // suggestion range 4-11, generated after `ABCD` - /// ``` - /// The suggestion should contain `012`, aka, the suggestion that is typed. - /// - /// Another case is that the suggestion may contain the whole line. - /// /// ``` - /// ABCD012 // typed text - /// ----^ - /// ABCD0123456 // suggestion range 0-11, generated after `ABCD` - /// The suggestion should contain `ABCD012`, aka, the suggestion that is typed. - /// ``` - let typedSuggestion = { - assert( - suggestion.range.start.character >= 0, - "Generating suggestion with invalid range" - ) - - let utf16View = editingLine.utf16 - - let startIndex = utf16View.index( - utf16View.startIndex, - offsetBy: max(0, suggestion.range.start.character), - limitedBy: utf16View.endIndex - ) ?? utf16View.startIndex - - let endIndex = utf16View.index( - utf16View.startIndex, - offsetBy: cursorPosition.character, - limitedBy: utf16View.endIndex - ) ?? utf16View.endIndex - - if endIndex > startIndex { - return String(editingLine[startIndex..= suggestionFirstLine.utf16.count + suggestion.range.start.character - { - return false - } + if Self.validateThatSuggestionMakeNoDifferent( + suggestion, + lines: lines, + suggestionLines: suggestionLines + ) { + return false } - // the line content doesn't match the suggestion - if cursorPosition.character > 0, - !suggestionFirstLine.hasPrefix(typedSuggestion) - { + // the line content doesn't match the suggestion snapshot + if !editingLine.hasPrefix(snapshot.editingLinePrefix) { return false } - // finished typing the whole suggestion when the suggestion has only one line - if typedSuggestion.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 { + return true + } + + static func validateThatSuggestionMakeNoDifferent( + _ suggestion: CodeSuggestion, + lines: [String], + suggestionLines: [Substring] + ) -> Bool { + var editingRange = suggestion.range + let startLine = max(0, editingRange.start.line) + let endLine = max(startLine, min(editingRange.end.line, lines.count - 1)) + + // The editing range is out of the file + if startLine < 0 || endLine >= lines.count { return false } - // undo to a state before the suggestion was generated - if editingLine.utf16.count < suggestion.position.character { + // The suggestion is apparently longer than the editing range + if endLine - startLine + 1 != suggestionLines.count { return false } - return true + let originalEditingLines = Array(lines[startLine...endLine]) + var editingLines = originalEditingLines + editingRange.end = .init( + line: editingRange.end.line - editingRange.start.line, + character: editingRange.end.character + ) + editingRange.start = .init(line: 0, character: editingRange.start.character) + var cursorPosition = CursorPosition( + line: suggestion.position.line - startLine, + character: suggestion.position.character + ) + let pseudoSuggestion = CodeSuggestion( + id: "", + text: suggestion.text, + position: cursorPosition, + range: editingRange + ) + var extraInfo = SuggestionInjector.ExtraInfo() + let injector = SuggestionInjector() + injector.acceptSuggestion( + intoContentWithoutSuggestion: &editingLines, + cursorPosition: &cursorPosition, + completion: pseudoSuggestion, + extraInfo: &extraInfo + ) + return editingLines == originalEditingLines } } diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift index 2fbaddd3..a6f22b2a 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -47,7 +47,7 @@ public extension Workspace { filespace.codeMetadata.guessLineEnding(from: editor.lines.first) let snapshot = FilespaceSuggestionSnapshot( - linesHash: editor.lines.hashValue, + lines: editor.lines, cursorPosition: editor.cursorPosition ) From cab09478fb8519a7afccdfd84ef27d3b54a70c5f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jul 2024 16:43:03 +0800 Subject: [PATCH 018/116] Add description to CodeSuggestion --- .../SuggestionBasic/CodeSuggestion.swift | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index 32adab0b..dbc20b08 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -1,12 +1,28 @@ -import Foundation import CodableWrappers +import Foundation public struct CodeSuggestion: Codable, Equatable { + public struct Description: Codable, Equatable { + public enum Kind: Codable, Equatable { + case warning + case action + } + + public var kind: Kind + public var content: String + + public init(kind: Kind, content: String) { + self.kind = kind + self.content = content + } + } + public init( id: String, text: String, position: CursorPosition, range: CursorRange, + descriptions: [Description], middlewareComments: [String] = [], metadata: [String: String] = [:] ) { @@ -14,15 +30,18 @@ public struct CodeSuggestion: Codable, Equatable { self.position = position self.id = id self.range = range + self.descriptions = descriptions self.middlewareComments = middlewareComments self.metadata = metadata } public static func == (lhs: CodeSuggestion, rhs: CodeSuggestion) -> Bool { - return lhs.text == rhs.text - && lhs.position == rhs.position - && lhs.id == rhs.id - && lhs.range == rhs.range + return lhs.text == rhs.text + && lhs.position == rhs.position + && lhs.id == rhs.id + && lhs.range == rhs.range + && lhs.descriptions == rhs.descriptions + && lhs.middlewareComments == rhs.middlewareComments } /// The new code to be inserted and the original code on the first line. @@ -33,6 +52,8 @@ public struct CodeSuggestion: Codable, Equatable { public var id: String /// The range of the original code that should be replaced. public var range: CursorRange + /// Descriptions about this code suggestion + @FallbackDecoding public var descriptions: [Description] /// A place to store comments inserted by middleware for debugging use. @FallbackDecoding public var middlewareComments: [String] /// A place to store extra data. From b1428985498842e6f9ac5680334aa01f2a10b719 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jul 2024 16:53:16 +0800 Subject: [PATCH 019/116] Add current line to CurosrPositionTracker --- .../Sources/SuggestionWidget/CursorPositionTracker.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift index 35f74326..9591a001 100644 --- a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift +++ b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift @@ -8,6 +8,8 @@ import XcodeInspector final class CursorPositionTracker { @MainActor var cursorPosition: CursorPosition = .zero + @MainActor + var currentLine: String = "" @PerceptionIgnored var editorObservationTask: Set = [] @PerceptionIgnored var eventObservationTask: Task? @@ -37,6 +39,13 @@ final class CursorPositionTracker { let content = editor.getLatestEvaluatedContent() Task { @MainActor in self.cursorPosition = content.cursorPosition + self.currentLine = if content.cursorPosition.line >= 0, + content.cursorPosition.line < content.lines.count + { + content.lines[content.cursorPosition.line] + } else { + "" + } } eventObservationTask = Task { [weak self] in for await event in await editor.axNotifications.notifications() { From 3054a7112f9fb2199f039a913a55987d4bae0129 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jul 2024 16:54:02 +0800 Subject: [PATCH 020/116] Rename to TextCursorTracker --- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 2 +- .../{CursorPositionTracker.swift => TextCursorTracker.swift} | 2 +- Core/Sources/SuggestionWidget/WidgetWindowsController.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename Core/Sources/SuggestionWidget/{CursorPositionTracker.swift => TextCursorTracker.swift} (98%) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index a0125683..bc54abbf 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -7,7 +7,7 @@ import XcodeInspector struct CodeBlockSuggestionPanel: View { let suggestion: CodeSuggestionProvider - @Environment(CursorPositionTracker.self) var cursorPositionTracker + @Environment(TextCursorTracker.self) var cursorPositionTracker @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFont) var codeFont @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift similarity index 98% rename from Core/Sources/SuggestionWidget/CursorPositionTracker.swift rename to Core/Sources/SuggestionWidget/TextCursorTracker.swift index 9591a001..61d669f8 100644 --- a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -5,7 +5,7 @@ import SuggestionBasic import XcodeInspector @Perceptible -final class CursorPositionTracker { +final class TextCursorTracker { @MainActor var cursorPosition: CursorPosition = .zero @MainActor diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index b734cc54..7caf1336 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -651,7 +651,7 @@ public final class WidgetWindows { let store: StoreOf let chatTabPool: ChatTabPool weak var controller: WidgetWindowsController? - let cursorPositionTracker = CursorPositionTracker() + let cursorPositionTracker = TextCursorTracker() // you should make these window `.transient` so they never show up in the mission control. From d7fad1c8228e05e969bf8f88601f59290cd6b6b9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jul 2024 21:54:56 +0800 Subject: [PATCH 021/116] Update TextCursorTracker --- .../SuggestionWidget/TextCursorTracker.swift | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift index 61d669f8..574949b0 100644 --- a/Core/Sources/SuggestionWidget/TextCursorTracker.swift +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -4,12 +4,29 @@ import Perception import SuggestionBasic import XcodeInspector +/// A passive tracker that observe the changes of the source editor content. @Perceptible final class TextCursorTracker { @MainActor - var cursorPosition: CursorPosition = .zero + var cursorPosition: CursorPosition { content.cursorPosition } @MainActor - var currentLine: String = "" + var currentLine: String { + if content.cursorPosition.line >= 0, content.cursorPosition.line < content.lines.count { + content.lines[content.cursorPosition.line] + } else { + "" + } + } + + @MainActor + var content: SourceEditor.Content = .init( + content: "", + lines: [], + selections: [], + cursorPosition: .zero, + cursorOffset: 0, + lineAnnotations: [] + ) @PerceptionIgnored var editorObservationTask: Set = [] @PerceptionIgnored var eventObservationTask: Task? @@ -38,14 +55,7 @@ final class TextCursorTracker { eventObservationTask?.cancel() let content = editor.getLatestEvaluatedContent() Task { @MainActor in - self.cursorPosition = content.cursorPosition - self.currentLine = if content.cursorPosition.line >= 0, - content.cursorPosition.line < content.lines.count - { - content.lines[content.cursorPosition.line] - } else { - "" - } + self.content = content } eventObservationTask = Task { [weak self] in for await event in await editor.axNotifications.notifications() { @@ -53,7 +63,7 @@ final class TextCursorTracker { guard event.kind == .evaluatedContentChanged else { continue } let content = editor.getLatestEvaluatedContent() Task { @MainActor in - self.cursorPosition = content.cursorPosition + self.content = content } } } From 00760a7b8e8fcaabf8e37706b7234183e8838697 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 15 Jul 2024 15:01:07 +0800 Subject: [PATCH 022/116] Avoid Xcode comlaining about dependency cycle --- Core/Package.swift | 1 + Tool/Package.swift | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index e8778e42..64ef83fc 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -82,6 +82,7 @@ let package = Package( .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "Workspace", package: "Tool"), + .product(name: "WorkspaceSuggestionService", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), diff --git a/Tool/Package.swift b/Tool/Package.swift index 1e45ef89..b16a2d6d 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -27,7 +27,8 @@ let package = Package( .library(name: "Keychain", targets: ["Keychain"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), - .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library(name: "Workspace", targets: ["Workspace"]), + .library(name: "WorkspaceSuggestionService", targets: ["WorkspaceSuggestionService"]), .library( name: "SuggestionProvider", targets: ["SuggestionProvider", "GitHubCopilotService", "CodeiumService"] @@ -159,7 +160,7 @@ let package = Package( .product(name: "CodableWrappers", package: "CodableWrappers"), ] ), - + .target( name: "SuggestionInjector", dependencies: ["SuggestionBasic"] @@ -180,7 +181,7 @@ let package = Package( name: "SuggestionBasicTests", dependencies: ["SuggestionBasic"] ), - + .target( name: "ChatBasic", dependencies: [ @@ -303,7 +304,7 @@ let package = Package( ), ] ), - + .target( name: "CommandHandler", dependencies: [ From e9440ea1fd23eaaf0c9ed81c2f844a4a098bfdab Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 15 Jul 2024 15:02:04 +0800 Subject: [PATCH 023/116] Remove CodeSuggestionProvider in favor of PresentingCodeSuggestion and CommandHandler --- .../Service/GUI/WidgetDataSource.swift | 37 +----- .../FeatureReducers/PanelFeature.swift | 4 +- .../FeatureReducers/SharedPanelFeature.swift | 2 +- .../SuggestionPanelFeature.swift | 2 +- .../Providers/CodeSuggestionProvider.swift | 60 ---------- .../SuggestionWidget/SharedPanelView.swift | 2 +- .../CodeBlockSuggestionPanel.swift | 107 ++++++++++++++---- .../SuggestionWidgetDataSource.swift | 6 +- .../SuggestionBasic/CodeSuggestion.swift | 2 +- 9 files changed, 99 insertions(+), 123 deletions(-) delete mode 100644 Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 77dd8993..5fb5e9d8 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -14,7 +14,7 @@ import SuggestionWidget final class WidgetDataSource {} extension WidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? { for workspace in Service.shared.workspacePool.workspaces.values { if let filespace = workspace.filespaces[url], let suggestion = filespace.presentingSuggestion @@ -24,40 +24,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { language: filespace.language.rawValue, startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, - currentSuggestionIndex: filespace.suggestionIndex, - onSelectPreviousSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.presentPreviousSuggestion() - } - }, - onSelectNextSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.presentNextSuggestion() - } - }, - onRejectSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.rejectSuggestions() - NSWorkspace.activatePreviousActiveXcode() - } - }, - onAcceptSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.acceptSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } - }, - onDismissSuggestionTapped: { - Task { - let handler = PseudoCommandHandler() - await handler.dismissSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } - } + currentSuggestionIndex: filespace.suggestionIndex ) } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 0467da4b..a05a03cc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -25,7 +25,7 @@ public struct PanelFeature { public enum Action: Equatable { case presentSuggestion - case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool) case presentError(String) case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) case displayPanelContent @@ -136,7 +136,7 @@ public struct PanelFeature { } } - func fetchSuggestionProvider(fileURL: URL) async -> CodeSuggestionProvider? { + func fetchSuggestionProvider(fileURL: URL) async -> PresentingCodeSuggestion? { guard let provider = await suggestionWidgetControllerDependency .suggestionWidgetDataSource? .suggestionForFile(at: fileURL) else { return nil } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift index 232f29f4..24636845 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -6,7 +6,7 @@ import SwiftUI public struct SharedPanelFeature { public struct Content: Equatable { public var promptToCodeGroup = PromptToCodeGroup.State() - var suggestion: CodeSuggestionProvider? + var suggestion: PresentingCodeSuggestion? public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } var error: String? } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift index 00805391..4521c54c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -6,7 +6,7 @@ import SwiftUI public struct SuggestionPanelFeature { @ObservableState public struct State: Equatable { - var content: CodeSuggestionProvider? + var content: PresentingCodeSuggestion? var colorScheme: ColorScheme = .light var alignTopToAnchor = false var isPanelDisplayed: Bool = false diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift deleted file mode 100644 index dd50233f..00000000 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Combine -import Foundation -import Perception -import SharedUIComponents -import SwiftUI -import XcodeInspector - -@Perceptible -public final class CodeSuggestionProvider: Equatable { - public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool { - lhs.code == rhs.code && lhs.language == rhs.language - } - - public var code: String = "" - public var language: String = "" - public var startLineIndex: Int = 0 - public var suggestionCount: Int = 0 - public var currentSuggestionIndex: Int = 0 - public var extraInformation: String = "" - - @PerceptionIgnored public var onSelectPreviousSuggestionTapped: () -> Void - @PerceptionIgnored public var onSelectNextSuggestionTapped: () -> Void - @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void - @PerceptionIgnored public var onAcceptSuggestionTapped: () -> Void - @PerceptionIgnored public var onDismissSuggestionTapped: () -> Void - - public init( - code: String = "", - language: String = "", - startLineIndex: Int = 0, - startCharacerIndex: Int = 0, - suggestionCount: Int = 0, - currentSuggestionIndex: Int = 0, - onSelectPreviousSuggestionTapped: @escaping () -> Void = {}, - onSelectNextSuggestionTapped: @escaping () -> Void = {}, - onRejectSuggestionTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {}, - onDismissSuggestionTapped: @escaping () -> Void = {} - ) { - self.code = code - self.language = language - self.startLineIndex = startLineIndex - self.suggestionCount = suggestionCount - self.currentSuggestionIndex = currentSuggestionIndex - self.onSelectPreviousSuggestionTapped = onSelectPreviousSuggestionTapped - self.onSelectNextSuggestionTapped = onSelectNextSuggestionTapped - self.onRejectSuggestionTapped = onRejectSuggestionTapped - self.onAcceptSuggestionTapped = onAcceptSuggestionTapped - self.onDismissSuggestionTapped = onDismissSuggestionTapped - } - - func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() } - func selectNextSuggestion() { onSelectNextSuggestionTapped() } - func rejectSuggestion() { onRejectSuggestionTapped() } - func acceptSuggestion() { onAcceptSuggestionTapped() } - func dismissSuggestion() { onDismissSuggestionTapped() } - - -} - diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 697a0663..3e91b903 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -101,7 +101,7 @@ struct SharedPanelView: View { } @ViewBuilder - func suggestion(_ suggestion: CodeSuggestionProvider) -> some View { + func suggestion(_ suggestion: PresentingCodeSuggestion) -> some View { switch suggestionPresentationMode { case .nearbyTextCursor: EmptyView() diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index bc54abbf..5eac6150 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -1,13 +1,37 @@ import Combine +import CommandHandler +import Dependencies import Perception import SharedUIComponents import SuggestionBasic import SwiftUI import XcodeInspector +public struct PresentingCodeSuggestion: Equatable { + public var code: String + public var language: String + public var startLineIndex: Int + public var suggestionCount: Int + public var currentSuggestionIndex: Int + + public init( + code: String, + language: String, + startLineIndex: Int, + suggestionCount: Int, + currentSuggestionIndex: Int + ) { + self.code = code + self.language = language + self.startLineIndex = startLineIndex + self.suggestionCount = suggestionCount + self.currentSuggestionIndex = currentSuggestionIndex + } +} + struct CodeBlockSuggestionPanel: View { - let suggestion: CodeSuggestionProvider - @Environment(TextCursorTracker.self) var cursorPositionTracker + let suggestion: PresentingCodeSuggestion + @Environment(TextCursorTracker.self) var textCursorTracker @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFont) var codeFont @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode @@ -20,13 +44,17 @@ struct CodeBlockSuggestionPanel: View { @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark struct ToolBar: View { - let suggestion: CodeSuggestionProvider + @Dependency(\.commandHandler) var commandHandler + let suggestion: PresentingCodeSuggestion var body: some View { WithPerceptionTracking { HStack { Button(action: { - suggestion.selectPreviousSuggestion() + Task { + await commandHandler.presentPreviousSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Image(systemName: "chevron.left") }.buttonStyle(.plain) @@ -37,7 +65,10 @@ struct CodeBlockSuggestionPanel: View { .monospacedDigit() Button(action: { - suggestion.selectNextSuggestion() + Task { + await commandHandler.presentNextSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Image(systemName: "chevron.right") }.buttonStyle(.plain) @@ -45,19 +76,28 @@ struct CodeBlockSuggestionPanel: View { Spacer() Button(action: { - suggestion.dismissSuggestion() + Task { + await commandHandler.dismissSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) }.buttonStyle(.plain) Button(action: { - suggestion.rejectSuggestion() + Task { + await commandHandler.rejectSuggestions() + NSWorkspace.activatePreviousActiveXcode() + } }) { Text("Reject") }.buttonStyle(CommandButtonStyle(color: .gray)) Button(action: { - suggestion.acceptSuggestion() + Task { + await commandHandler.acceptSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Text("Accept") }.buttonStyle(CommandButtonStyle(color: .accentColor)) @@ -70,13 +110,17 @@ struct CodeBlockSuggestionPanel: View { } struct CompactToolBar: View { - let suggestion: CodeSuggestionProvider + @Dependency(\.commandHandler) var commandHandler + let suggestion: PresentingCodeSuggestion var body: some View { WithPerceptionTracking { HStack { Button(action: { - suggestion.selectPreviousSuggestion() + Task { + await commandHandler.presentPreviousSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Image(systemName: "chevron.left") }.buttonStyle(.plain) @@ -87,7 +131,10 @@ struct CodeBlockSuggestionPanel: View { .monospacedDigit() Button(action: { - suggestion.selectNextSuggestion() + Task { + await commandHandler.presentNextSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Image(systemName: "chevron.right") }.buttonStyle(.plain) @@ -95,7 +142,10 @@ struct CodeBlockSuggestionPanel: View { Spacer() Button(action: { - suggestion.dismissSuggestion() + Task { + await commandHandler.dismissSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Image(systemName: "xmark") }.buttonStyle(.plain) @@ -113,6 +163,11 @@ struct CodeBlockSuggestionPanel: View { VStack(spacing: 0) { CustomScrollView { WithPerceptionTracking { + let diffResult = Self.diff( + suggestion: suggestion, + textCursorTracker: textCursorTracker + ) + AsyncCodeBlock( code: suggestion.code, language: suggestion.language, @@ -134,10 +189,7 @@ struct CodeBlockSuggestionPanel: View { } return nil }(), - dimmedCharacterCount: suggestion.startLineIndex - == cursorPositionTracker.cursorPosition.line - ? cursorPositionTracker.cursorPosition.character - : 0 + dimmedCharacterCount: 0 ) .frame(maxWidth: .infinity) .background({ () -> Color in @@ -169,12 +221,29 @@ struct CodeBlockSuggestionPanel: View { }()) } } + + struct DiffResult { + var dimmedRanges: [Range] + var mutatedRanges: [Range] + var deletedRanges: [Range] + } + + @MainActor + static func diff( + suggestion: PresentingCodeSuggestion, + textCursorTracker: TextCursorTracker + ) -> DiffResult { + let typedContentCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line + ? textCursorTracker.cursorPosition.character + : 0 + return .init(dimmedRanges: [], mutatedRanges: [], deletedRanges: []) + } } // MARK: - Previews #Preview("Code Block Suggestion Panel") { - CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider( + CodeBlockSuggestionPanel(suggestion: PresentingCodeSuggestion( code: """ LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { ForEach(0.. CodeSuggestionProvider? + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? } struct MockWidgetDataSource: SuggestionWidgetDataSource { - func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { - return CodeSuggestionProvider( + func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? { + return PresentingCodeSuggestion( code: """ func test() { let x = 1 diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index dbc20b08..c8cc7347 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -22,7 +22,7 @@ public struct CodeSuggestion: Codable, Equatable { text: String, position: CursorPosition, range: CursorRange, - descriptions: [Description], + descriptions: [Description] = [], middlewareComments: [String] = [], metadata: [String: String] = [:] ) { From 584944f4a929136b05b3f3c3b2764c6715414ad9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 15 Jul 2024 23:40:06 +0800 Subject: [PATCH 024/116] Add CodeDiff --- Core/Package.swift | 1 + Tool/Package.swift | 4 + Tool/Sources/CodeDiff/CodeDiff.swift | 435 +++++++++++++++++++ Tool/Tests/CodeDiffTests/CodeDiffTests.swift | 9 + 4 files changed, 449 insertions(+) create mode 100644 Tool/Sources/CodeDiff/CodeDiff.swift create mode 100644 Tool/Tests/CodeDiffTests/CodeDiffTests.swift diff --git a/Core/Package.swift b/Core/Package.swift index 64ef83fc..dff74b41 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -226,6 +226,7 @@ let package = Package( .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "CodeDiff", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), diff --git a/Tool/Package.swift b/Tool/Package.swift index b16a2d6d..92899823 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -48,6 +48,7 @@ let package = Package( .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), .library(name: "CommandHandler", targets: ["CommandHandler"]), + .library(name: "CodeDiff", targets: ["CodeDiff"]), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. @@ -95,6 +96,9 @@ let package = Package( .target(name: "ObjectiveCExceptionHandling"), + .target(name: "CodeDiff", dependencies: ["SuggestionBasic"]), + .testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]), + .target( name: "CustomAsyncAlgorithms", dependencies: [ diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift new file mode 100644 index 00000000..5a48020d --- /dev/null +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -0,0 +1,435 @@ +import Foundation +import SuggestionBasic + +public struct CodeDiff { + public typealias LineDiff = CollectionDifference + + public struct SnippetDiff { + public struct Change { + public var offset: Int + public var element: String + } + + public struct Line { + public var text: String + public var diff: [Change]? + } + + public struct Section { + public var oldSnippet: [Line] + public var newSnippet: [Line] + } + + public var sections: [Section] + + public func line(at index: Int, in keyPath: KeyPath) -> Line? { + var previousSectionEnd = 0 + for section in sections { + let lines = section[keyPath: keyPath] + let index = index - previousSectionEnd + if index < lines.endIndex { + return lines[index] + } + previousSectionEnd += lines.endIndex + } + return nil + } + } + + public func diff(text: String, from oldText: String) -> LineDiff { + typealias Change = LineDiff.Change + let diffByCharacter = text.difference(from: oldText) + var result = [Change]() + + var current: Change? + for item in diffByCharacter { + if let this = current { + switch (this, item) { + case let (.insert(offset, element, associatedWith), .insert(offsetB, elementB, _)) + where offset + element.count == offsetB: + current = .insert( + offset: offset, + element: element + String(elementB), + associatedWith: associatedWith + ) + continue + case let (.remove(offset, element, associatedWith), .remove(offsetB, elementB, _)) + where offset - 1 == offsetB: + current = .remove( + offset: offsetB, + element: String(elementB) + element, + associatedWith: associatedWith + ) + continue + default: + result.append(this) + } + } + + current = switch item { + case let .insert(offset, element, associatedWith): + .insert(offset: offset, element: String(element), associatedWith: associatedWith) + case let .remove(offset, element, associatedWith): + .remove(offset: offset, element: String(element), associatedWith: associatedWith) + } + } + + if let current { + result.append(current) + } + + return .init(result) ?? [].difference(from: []) + } + + public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff { + let newLines = snippet.breakLines() + let oldLines = oldSnippet.breakLines() + let diffByLine = newLines.difference(from: oldLines) + + struct DiffSection: Equatable { + var offset: Int + var end: Int + var lines: [String] + } + + func collect( + into all: inout [DiffSection], + changes: [CollectionDifference.Change], + extract: (CollectionDifference.Change) -> (offset: Int, line: String)? + ) { + var current: DiffSection? + for change in changes { + guard let (offset, element) = extract(change) else { continue } + if var section = current { + if offset == section.end + 1 { + section.lines.append(element) + section.end = offset + current = section + continue + } else { + all.append(section) + } + } + + current = DiffSection(offset: offset, end: offset, lines: [element]) + } + + if let current { + all.append(current) + } + } + + var insertions = [DiffSection]() + var removals = [DiffSection]() + + collect(into: &removals, changes: diffByLine.removals) { change in + guard case let .remove(offset, element, _) = change else { return nil } + return (offset, element) + } + + collect(into: &insertions, changes: diffByLine.insertions) { change in + guard case let .insert(offset, element, _) = change else { return nil } + return (offset, element) + } + + var oldLineIndex = 0 + var newLineIndex = 0 + var sectionIndex = 0 + var result = SnippetDiff(sections: []) + + while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex { + let removalSection = removals[safe: sectionIndex] + let insertionSection = insertions[safe: sectionIndex] + + // handle lines before sections + var beforeSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) + + while oldLineIndex < (removalSection?.offset ?? .max) { + beforeSection.oldSnippet.append(.init(text: oldLines[oldLineIndex], diff: nil)) + oldLineIndex += 1 + } + while newLineIndex < (insertionSection?.offset ?? .max) { + beforeSection.newSnippet.append(.init(text: newLines[newLineIndex], diff: nil)) + newLineIndex += 1 + } + + result.sections.append(beforeSection) + + // handle lines inside sections + + var insideSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) + + for i in 0.. Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } + + subscript(safe index: Int, fallback fallback: Element) -> Element { + guard index >= 0, index < count else { return fallback } + return self[index] + } +} + +extension CodeDiff.LineDiff.Change { + var range: NSRange { + switch self { + case let .insert(offset, element, _): + return NSRange(location: offset, length: element.count) + case let .remove(offset, element, _): + return NSRange(location: offset, length: element.count) + } + } + + var element: String { + switch self { + case let .insert(_, element, _): + return element + case let .remove(_, element, _): + return element + } + } + + var offset: Int { + switch self { + case let .insert(offset, _, _): + return offset + case let .remove(offset, _, _): + return offset + } + } + + func earlierThan(offset: Int) -> Bool { + range.upperBound < offset + } + + func rebased(in subRange: NSRange, text: String) -> Self? { + let thisRange = range + guard let intersection = thisRange.intersection(subRange) else { return nil } + let rebasedLocation = max(0, intersection.location - subRange.location) + let length = intersection.length + let rebasedRange = Range(NSRange(location: rebasedLocation, length: length), in: text) + let element = if let rebasedRange { + String(text[rebasedRange]) + } else { + "" + } + switch self { + case let .insert(_, _, associatedWith): + return .insert( + offset: rebasedLocation, + element: element, + associatedWith: associatedWith + ) + case let .remove(_, _, associatedWith): + return .remove( + offset: rebasedLocation, + element: element, + associatedWith: associatedWith + ) + } + } +} + +#if DEBUG + +import SwiftUI + +struct SnippetDiffPreview: View { + let originalCode: String + let newCode: String + + var body: some View { + HStack(alignment: .top) { + let (original, new) = generateTexts() + block(original) + Divider() + block(new) + } + .padding() + .font(.body.monospaced()) + } + + @ViewBuilder + func block(_ code: [AttributedString]) -> some View { + VStack(alignment: .leading) { + if !code.isEmpty { + ForEach(0.. (original: [AttributedString], new: [AttributedString]) { + let diff = CodeDiff().diff(snippet: newCode, from: originalCode) + + let new = diff.sections.flatMap { + $0.newSnippet.map { + let text = $0.text.trimmingCharacters(in: .newlines) + let string = NSMutableAttributedString(string: text) + if let diff = $0.diff { + string.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.1), + range: NSRange(location: 0, length: text.count) + ) + + for diffItem in diff { + string.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.5), + range: NSRange( + location: diffItem.offset, + length: min(text.count - diffItem.offset, diffItem.element.count) + ) + ) + } + } + return string + } + } + + let original = diff.sections.flatMap { + $0.oldSnippet.map { + let text = $0.text.trimmingCharacters(in: .newlines) + let string = NSMutableAttributedString(string: text) + if let diff = $0.diff { + string.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.1), + range: NSRange(location: 0, length: text.count) + ) + + for diffItem in diff { + string.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.5), + range: NSRange( + location: diffItem.offset, + length: min(text.count - diffItem.offset, diffItem.element.count) + ) + ) + } + } + + return string + } + } + + return (original.map(AttributedString.init), new.map(AttributedString.init)) + } +} + +struct LineDiffPreview: View { + let originalCode: String + let newCode: String + + var body: some View { + VStack(alignment: .leading) { + let (original, new) = generateTexts() + Text(original) + Divider() + Text(new) + } + .padding() + .font(.body.monospaced()) + } + + func generateTexts() -> (original: AttributedString, new: AttributedString) { + let diff = CodeDiff().diff(text: newCode, from: originalCode) + let original = NSMutableAttributedString(string: originalCode) + let new = NSMutableAttributedString(string: newCode) + + for item in diff { + switch item { + case let .insert(offset, element, _): + new.addAttribute( + .backgroundColor, + value: NSColor.green.withAlphaComponent(0.5), + range: NSRange(location: offset, length: element.count) + ) + case let .remove(offset, element, _): + original.addAttribute( + .backgroundColor, + value: NSColor.red.withAlphaComponent(0.5), + range: NSRange(location: offset, length: element.count) + ) + } + } + + return (.init(original), .init(new)) + } +} + +#Preview("Line Diff") { + let originalCode = """ + let foo = Foo() // yes + """ + let newCode = """ + var foo = Bar() + """ + + return LineDiffPreview(originalCode: originalCode, newCode: newCode) +} + +#Preview("Snippet Diff") { + let originalCode = """ + let foo = Foo() + print(foo) + // do something + foo.foo() + func zoo() {} + """ + let newCode = """ + var foo = Bar() + // do something + foo.bar() + func zoo() { + print("zoo") + } + """ + + return SnippetDiffPreview(originalCode: originalCode, newCode: newCode) +} + +#endif + diff --git a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift new file mode 100644 index 00000000..bdd522ac --- /dev/null +++ b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift @@ -0,0 +1,9 @@ +import Foundation +import XCTest + +@testable import CodeDiff + +class CodeDiffTests: XCTestCase { + func test_diff_snippets() {} +} + From 9e8e612cf4a7a9ba2ba6006a09634044ba4de96c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jul 2024 14:37:08 +0800 Subject: [PATCH 025/116] Remove pseudo lines from diff result --- Tool/Sources/CodeDiff/CodeDiff.swift | 38 +++++++++++++++------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift index 5a48020d..e7775733 100644 --- a/Tool/Sources/CodeDiff/CodeDiff.swift +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -160,23 +160,27 @@ public struct CodeDiff { var insideSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) for i in 0.. Date: Tue, 16 Jul 2024 14:40:27 +0800 Subject: [PATCH 026/116] Remove unused code --- Tool/Sources/CodeDiff/CodeDiff.swift | 60 ---------------------------- 1 file changed, 60 deletions(-) diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift index e7775733..768041d6 100644 --- a/Tool/Sources/CodeDiff/CodeDiff.swift +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -206,66 +206,6 @@ extension Array { } } -extension CodeDiff.LineDiff.Change { - var range: NSRange { - switch self { - case let .insert(offset, element, _): - return NSRange(location: offset, length: element.count) - case let .remove(offset, element, _): - return NSRange(location: offset, length: element.count) - } - } - - var element: String { - switch self { - case let .insert(_, element, _): - return element - case let .remove(_, element, _): - return element - } - } - - var offset: Int { - switch self { - case let .insert(offset, _, _): - return offset - case let .remove(offset, _, _): - return offset - } - } - - func earlierThan(offset: Int) -> Bool { - range.upperBound < offset - } - - func rebased(in subRange: NSRange, text: String) -> Self? { - let thisRange = range - guard let intersection = thisRange.intersection(subRange) else { return nil } - let rebasedLocation = max(0, intersection.location - subRange.location) - let length = intersection.length - let rebasedRange = Range(NSRange(location: rebasedLocation, length: length), in: text) - let element = if let rebasedRange { - String(text[rebasedRange]) - } else { - "" - } - switch self { - case let .insert(_, _, associatedWith): - return .insert( - offset: rebasedLocation, - element: element, - associatedWith: associatedWith - ) - case let .remove(_, _, associatedWith): - return .remove( - offset: rebasedLocation, - element: element, - associatedWith: associatedWith - ) - } - } -} - #if DEBUG import SwiftUI From bed5cddea822ee8067ff29404289c2e0a09fc10f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jul 2024 15:37:32 +0800 Subject: [PATCH 027/116] WIP --- Tool/Sources/CodeDiff/CodeDiff.swift | 78 ++++--- Tool/Tests/CodeDiffTests/CodeDiffTests.swift | 228 ++++++++++++++++++- 2 files changed, 279 insertions(+), 27 deletions(-) diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift index 768041d6..718905cc 100644 --- a/Tool/Sources/CodeDiff/CodeDiff.swift +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -4,20 +4,29 @@ import SuggestionBasic public struct CodeDiff { public typealias LineDiff = CollectionDifference - public struct SnippetDiff { - public struct Change { + public struct SnippetDiff: Equatable { + public struct Change: Equatable { public var offset: Int public var element: String } - public struct Line { + public struct Line: Equatable { + public enum Diff: Equatable { + case unchanged + case mutated(changes: [Change]) + } + public var text: String - public var diff: [Change]? + public var diff: Diff = .unchanged } - public struct Section { + public struct Section: Equatable { public var oldSnippet: [Line] public var newSnippet: [Line] + + public var isEmpty: Bool { + oldSnippet.isEmpty && newSnippet.isEmpty + } } public var sections: [Section] @@ -82,8 +91,8 @@ public struct CodeDiff { } public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff { - let newLines = snippet.breakLines() - let oldLines = oldSnippet.breakLines() + let newLines = snippet.splitByNewLine(omittingEmptySubsequences: false) + let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false) let diffByLine = newLines.difference(from: oldLines) struct DiffSection: Equatable { @@ -94,15 +103,15 @@ public struct CodeDiff { func collect( into all: inout [DiffSection], - changes: [CollectionDifference.Change], - extract: (CollectionDifference.Change) -> (offset: Int, line: String)? + changes: [CollectionDifference.Change], + extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)? ) { var current: DiffSection? for change in changes { guard let (offset, element) = extract(change) else { continue } if var section = current { if offset == section.end + 1 { - section.lines.append(element) + section.lines.append(String(element)) section.end = offset current = section continue @@ -111,7 +120,7 @@ public struct CodeDiff { } } - current = DiffSection(offset: offset, end: offset, lines: [element]) + current = DiffSection(offset: offset, end: offset, lines: [String(element)]) } if let current { @@ -144,16 +153,28 @@ public struct CodeDiff { // handle lines before sections var beforeSection = SnippetDiff.Section(oldSnippet: [], newSnippet: []) - while oldLineIndex < (removalSection?.offset ?? .max) { - beforeSection.oldSnippet.append(.init(text: oldLines[oldLineIndex], diff: nil)) + while oldLineIndex < (removalSection?.offset ?? oldLines.endIndex) { + if oldLineIndex < oldLines.endIndex { + beforeSection.oldSnippet.append(.init( + text: String(oldLines[oldLineIndex]), + diff: .unchanged + )) + } oldLineIndex += 1 } - while newLineIndex < (insertionSection?.offset ?? .max) { - beforeSection.newSnippet.append(.init(text: newLines[newLineIndex], diff: nil)) + while newLineIndex < (insertionSection?.offset ?? newLines.endIndex) { + if newLineIndex < newLines.endIndex { + beforeSection.newSnippet.append(.init( + text: String(newLines[newLineIndex]), + diff: .unchanged + )) + } newLineIndex += 1 } - result.sections.append(beforeSection) + if !beforeSection.isEmpty { + result.sections.append(beforeSection) + } // handle lines inside sections @@ -166,24 +187,26 @@ public struct CodeDiff { if let oldLine { insideSection.oldSnippet.append(.init( text: oldLine, - diff: diff.removals.compactMap { change in + diff: .mutated(changes: diff.removals.compactMap { change in guard case let .remove(offset, element, _) = change else { return nil } return .init(offset: offset, element: element) - } + }) )) } if let newLine { insideSection.newSnippet.append(.init( text: newLine, - diff: diff.insertions.compactMap { change in + diff: .mutated(changes: diff.insertions.compactMap { change in guard case let .insert(offset, element, _) = change else { return nil } return .init(offset: offset, element: element) - } + }) )) } } - result.sections.append(insideSection) + if !insideSection.isEmpty { + result.sections.append(insideSection) + } oldLineIndex += removalSection?.lines.count ?? 0 newLineIndex += insertionSection?.lines.count ?? 0 @@ -250,20 +273,23 @@ struct SnippetDiffPreview: View { $0.newSnippet.map { let text = $0.text.trimmingCharacters(in: .newlines) let string = NSMutableAttributedString(string: text) - if let diff = $0.diff { + if case let .mutated(changes) = $0.diff { string.addAttribute( .backgroundColor, value: NSColor.green.withAlphaComponent(0.1), range: NSRange(location: 0, length: text.count) ) - for diffItem in diff { + for diffItem in changes { string.addAttribute( .backgroundColor, value: NSColor.green.withAlphaComponent(0.5), range: NSRange( location: diffItem.offset, - length: min(text.count - diffItem.offset, diffItem.element.count) + length: min( + text.count - diffItem.offset, + diffItem.element.count + ) ) ) } @@ -276,14 +302,14 @@ struct SnippetDiffPreview: View { $0.oldSnippet.map { let text = $0.text.trimmingCharacters(in: .newlines) let string = NSMutableAttributedString(string: text) - if let diff = $0.diff { + if case let .mutated(changes) = $0.diff { string.addAttribute( .backgroundColor, value: NSColor.red.withAlphaComponent(0.1), range: NSRange(location: 0, length: text.count) ) - for diffItem in diff { + for diffItem in changes { string.addAttribute( .backgroundColor, value: NSColor.red.withAlphaComponent(0.5), diff --git a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift index bdd522ac..60ef2ab3 100644 --- a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift +++ b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift @@ -4,6 +4,232 @@ import XCTest @testable import CodeDiff class CodeDiffTests: XCTestCase { - func test_diff_snippets() {} + func test_diff_snippets_empty_snippets() { + XCTAssertEqual( + CodeDiff().diff(snippet: "", from: ""), + .init(sections: [ + .init(oldSnippet: [.init(text: "")], newSnippet: [.init(text: "")]), + ]) + ) + } + + func test_diff_snippets_from_empty_to_content() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + """, + from: "" + ), + .init(sections: [ + .init( + oldSnippet: [.init(text: "", diff: .mutated(changes: []))], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [.init( + offset: 0, + element: "let foo = Foo()" + )]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_from_content_to_empty() { + XCTAssertEqual( + CodeDiff().diff( + snippet: "", + from: """ + let foo = Foo() + foo.bar() + """ + ), + .init(sections: [ + .init( + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [.init( + offset: 0, + element: "let foo = Foo()" + )]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")]) + ), + ], + newSnippet: [.init(text: "", diff: .mutated(changes: []))] + ), + ]) + ) + } + + func test_diff_snippets_mutation() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + var foo = Bar() + foo.baz() + print(foo) + """, + from: """ + let foo = Foo() + foo.bar() + """ + ), + .init(sections: [ + .init( + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init( offset: 0, element: "let" ), + .init( offset: 10, element: "Foo" ), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r") + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init( offset: 0, element: "var" ), + .init( offset: 10, element: "Bar" ), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z") + ]) + ), + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)") + ]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + var foo = Bar() + foo.baz() + // divider a + print(foo) + // divider b + // divider c + func bar() { + print(foo) + } + """, + from: """ + let foo = Foo() + foo.bar() + // divider a + // divider b + // divider c + func bar() {} + """ + ), + .init(sections: [ + .init( + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init( offset: 0, element: "let" ), + .init( offset: 10, element: "Foo" ), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r") + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init( offset: 0, element: "var" ), + .init( offset: 10, element: "Bar" ), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z") + ]) + ), + ] + ), + .init( + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldSnippet: [], + newSnippet: [ + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)") + ]) + ), + ] + ), + .init( + oldSnippet: [.init(text: "// divider b"), .init(text: "// divider c")], + newSnippet: [.init(text: "// divider b"), .init(text: "// divider c")] + ), + .init( + oldSnippet: [ + .init( + text: "func bar() {}", + diff: .mutated(changes: [ + .init(offset: 12, element: "}") + ]) + ), + ], + newSnippet: [ + .init( + text: "func bar() {", + diff: .mutated(changes: []) + ), + .init( + text: " print(foo)", + diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")]) + ), + .init( + text: "}", + diff: .mutated(changes: [.init(offset: 0, element: "}")]) + ), + ] + ), + ]) + ) + } } From d61820b68aa9d2d337307f9f853cdbabcdfe308f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jul 2024 17:15:12 +0800 Subject: [PATCH 028/116] Fix diff section alignment --- Tool/Sources/CodeDiff/CodeDiff.swift | 177 ++++++++++++++++++++------- 1 file changed, 132 insertions(+), 45 deletions(-) diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift index 718905cc..617fbdfe 100644 --- a/Tool/Sources/CodeDiff/CodeDiff.swift +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -95,51 +95,11 @@ public struct CodeDiff { let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false) let diffByLine = newLines.difference(from: oldLines) - struct DiffSection: Equatable { - var offset: Int - var end: Int - var lines: [String] - } - - func collect( - into all: inout [DiffSection], - changes: [CollectionDifference.Change], - extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)? - ) { - var current: DiffSection? - for change in changes { - guard let (offset, element) = extract(change) else { continue } - if var section = current { - if offset == section.end + 1 { - section.lines.append(String(element)) - section.end = offset - current = section - continue - } else { - all.append(section) - } - } - - current = DiffSection(offset: offset, end: offset, lines: [String(element)]) - } - - if let current { - all.append(current) - } - } - - var insertions = [DiffSection]() - var removals = [DiffSection]() - - collect(into: &removals, changes: diffByLine.removals) { change in - guard case let .remove(offset, element, _) = change else { return nil } - return (offset, element) - } - - collect(into: &insertions, changes: diffByLine.insertions) { change in - guard case let .insert(offset, element, _) = change else { return nil } - return (offset, element) - } + let (insertions, removals) = generateDiffSections( + oldLines: oldLines, + newLines: newLines, + diffByLine: diffByLine + ) var oldLineIndex = 0 var newLineIndex = 0 @@ -217,6 +177,133 @@ public struct CodeDiff { } } +extension CodeDiff { + struct DiffSection: Equatable { + var offset: Int + var end: Int + var lines: [String] + + mutating func appendIfPossible(offset: Int, element: Substring) -> Bool { + if end + 1 != offset { return false } + end = offset + lines.append(String(element)) + return true + } + } + + func generateDiffSections( + oldLines: [Substring], + newLines: [Substring], + diffByLine: CollectionDifference + ) -> (insertionSections: [DiffSection], removalSections: [DiffSection]) { + let insertionDiffs = diffByLine.insertions + let removalDiffs = diffByLine.removals + var insertions = [DiffSection]() + var removals = [DiffSection]() + var insertionIndex = 0 + var removalIndex = 0 + var insertionUnchangedGap = 0 + var removalUnchangedGap = 0 + + while insertionIndex < insertionDiffs.endIndex || removalIndex < removalDiffs.endIndex { + let insertion = insertionDiffs[safe: insertionIndex] + let removal = removalDiffs[safe: removalIndex] + + append( + into: &insertions, + change: insertion, + index: &insertionIndex, + unchangedGap: &insertionUnchangedGap + ) { change in + guard case let .insert(offset, element, _) = change else { return nil } + return (offset, element) + } + + append( + into: &removals, + change: removal, + index: &removalIndex, + unchangedGap: &removalUnchangedGap + ) { change in + guard case let .remove(offset, element, _) = change else { return nil } + return (offset, element) + } + + if insertionUnchangedGap > removalUnchangedGap { + // insert empty sections to insertions + if removalUnchangedGap > 0 { + let count = insertionUnchangedGap - removalUnchangedGap + let index = max(insertions.endIndex - 1, 0) + let offset = (insertions.last?.offset ?? 0) - count + insertions.insert( + .init(offset: offset, end: offset, lines: []), + at: index + ) + insertionUnchangedGap -= removalUnchangedGap + removalUnchangedGap = 0 + } else if removal == nil { + removalUnchangedGap = 0 + insertionUnchangedGap = 0 + } + } else if removalUnchangedGap > insertionUnchangedGap { + // insert empty sections to removals + if insertionUnchangedGap > 0 { + let count = removalUnchangedGap - insertionUnchangedGap + let index = max(removals.endIndex - 1, 0) + let offset = (removals.last?.offset ?? 0) - count + removals.insert( + .init(offset: offset, end: offset, lines: []), + at: index + ) + removalUnchangedGap -= insertionUnchangedGap + insertionUnchangedGap = 0 + } else { + removalUnchangedGap = 0 + insertionUnchangedGap = 0 + } + } else { + removalUnchangedGap = 0 + insertionUnchangedGap = 0 + } + } + + return (insertions, removals) + } + + func append( + into sections: inout [DiffSection], + change: CollectionDifference.Change?, + index: inout Int, + unchangedGap: inout Int, + extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)? + ) { + guard let change, let (offset, element) = extract(change) else { return } + if unchangedGap == 0 { + if !sections.isEmpty { + let lastIndex = sections.endIndex - 1 + if !sections[lastIndex] + .appendIfPossible(offset: offset, element: element) + { + unchangedGap = offset - sections[lastIndex].end - 1 + sections.append(.init( + offset: offset, + end: offset, + lines: [String(element)] + )) + } + } else { + sections.append(.init( + offset: offset, + end: offset, + lines: [String(element)] + )) + unchangedGap = offset + } + index += 1 + } + } +} + extension Array { subscript(safe index: Int) -> Element? { guard index >= 0, index < count else { return nil } From 799ae2a74483959ed7a90e0d9bd92a7ff2c32c16 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jul 2024 17:15:19 +0800 Subject: [PATCH 029/116] Add tests for CodeDiff --- TestPlan.xctestplan | 7 + Tool/Package.swift | 4 + Tool/Sources/CodeDiff/CodeDiff.swift | 2 + Tool/Tests/CodeDiffTests/CodeDiffTests.swift | 297 +++++++++++++++++-- 4 files changed, 293 insertions(+), 17 deletions(-) diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 679b7891..ad5ee381 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -154,6 +154,13 @@ "identifier" : "SuggestionInjectorTests", "name" : "SuggestionInjectorTests" } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 92899823..2a14d559 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -246,6 +246,7 @@ let package = Package( "Preferences", "SuggestionBasic", "DebounceFunction", + "CodeDiff", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -327,6 +328,7 @@ let package = Package( "ObjectiveCExceptionHandling", "USearchIndex", "ChatBasic", + .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "SwiftSoup", package: "SwiftSoup"), ] @@ -358,6 +360,7 @@ let package = Package( "BuiltinExtension", "Toast", "SuggestionProvider", + .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ], @@ -381,6 +384,7 @@ let package = Package( "XcodeInspector", "BuiltinExtension", "ChatTab", + .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] ), diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift index 617fbdfe..a2d64642 100644 --- a/Tool/Sources/CodeDiff/CodeDiff.swift +++ b/Tool/Sources/CodeDiff/CodeDiff.swift @@ -2,6 +2,8 @@ import Foundation import SuggestionBasic public struct CodeDiff { + public init() {} + public typealias LineDiff = CollectionDifference public struct SnippetDiff: Equatable { diff --git a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift index 60ef2ab3..ce431133 100644 --- a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift +++ b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift @@ -72,7 +72,7 @@ class CodeDiffTests: XCTestCase { ]) ) } - + func test_diff_snippets_mutation() { XCTAssertEqual( CodeDiff().diff( @@ -92,14 +92,14 @@ class CodeDiffTests: XCTestCase { .init( text: "let foo = Foo()", diff: .mutated(changes: [ - .init( offset: 0, element: "let" ), - .init( offset: 10, element: "Foo" ), + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), ]) ), .init( text: "foo.bar()", diff: .mutated(changes: [ - .init(offset: 6, element: "r") + .init(offset: 6, element: "r"), ]) ), ], @@ -107,20 +107,20 @@ class CodeDiffTests: XCTestCase { .init( text: "var foo = Bar()", diff: .mutated(changes: [ - .init( offset: 0, element: "var" ), - .init( offset: 10, element: "Bar" ), + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), ]) ), .init( text: "foo.baz()", diff: .mutated(changes: [ - .init(offset: 6, element: "z") + .init(offset: 6, element: "z"), ]) ), .init( text: "print(foo)", diff: .mutated(changes: [ - .init(offset: 0, element: "print(foo)") + .init(offset: 0, element: "print(foo)"), ]) ), ] @@ -128,7 +128,7 @@ class CodeDiffTests: XCTestCase { ]) ) } - + func test_diff_snippets_multiple_sections() { XCTAssertEqual( CodeDiff().diff( @@ -158,14 +158,14 @@ class CodeDiffTests: XCTestCase { .init( text: "let foo = Foo()", diff: .mutated(changes: [ - .init( offset: 0, element: "let" ), - .init( offset: 10, element: "Foo" ), + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), ]) ), .init( text: "foo.bar()", diff: .mutated(changes: [ - .init(offset: 6, element: "r") + .init(offset: 6, element: "r"), ]) ), ], @@ -173,14 +173,14 @@ class CodeDiffTests: XCTestCase { .init( text: "var foo = Bar()", diff: .mutated(changes: [ - .init( offset: 0, element: "var" ), - .init( offset: 10, element: "Bar" ), + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), ]) ), .init( text: "foo.baz()", diff: .mutated(changes: [ - .init(offset: 6, element: "z") + .init(offset: 6, element: "z"), ]) ), ] @@ -195,7 +195,7 @@ class CodeDiffTests: XCTestCase { .init( text: "print(foo)", diff: .mutated(changes: [ - .init(offset: 0, element: "print(foo)") + .init(offset: 0, element: "print(foo)"), ]) ), ] @@ -209,7 +209,7 @@ class CodeDiffTests: XCTestCase { .init( text: "func bar() {}", diff: .mutated(changes: [ - .init(offset: 12, element: "}") + .init(offset: 12, element: "}"), ]) ), ], @@ -231,5 +231,268 @@ class CodeDiffTests: XCTestCase { ]) ) } + + func test_diff_snippets_multiple_sections_beginning_unchanged() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + // unchanged + // unchanged + var foo = Bar() + foo.baz() + // divider a + print(foo) + """, + from: """ + // unchanged + // unchanged + let foo = Foo() + foo.bar() + // divider a + """ + ), + .init(sections: [ + .init( + oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")], + newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")] + ), + .init( + oldSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ], + newSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ] + ), + .init( + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldSnippet: [], + newSnippet: [ + .init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + ), + ] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections_beginning_unchanged_reversed() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + // unchanged + // unchanged + let foo = Foo() + foo.bar() + // divider a + """, + from: """ + // unchanged + // unchanged + var foo = Bar() + foo.baz() + // divider a + print(foo) + """ + ), + .init(sections: [ + .init( + oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")], + newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")] + ), + .init( + oldSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ] + ), + .init( + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + )], + newSnippet: [] + ), + ]) + ) + } + + func test_diff_snippets_multiple_sections_more_unbalanced_sections_reversed() { + XCTAssertEqual( + CodeDiff().diff( + snippet: """ + let foo = Foo() + foo.bar() + // divider a + // divider b + // divider c + func bar() {} + """, + from: """ + var foo = Bar() + foo.baz() + // divider a + print(foo) + // divider b + print(foo) + // divider c + func bar() { + print(foo) + } + """ + ), + .init(sections: [ + .init( + oldSnippet: [ + .init( + text: "var foo = Bar()", + diff: .mutated(changes: [ + .init(offset: 0, element: "var"), + .init(offset: 10, element: "Bar"), + ]) + ), + .init( + text: "foo.baz()", + diff: .mutated(changes: [ + .init(offset: 6, element: "z"), + ]) + ), + ], + newSnippet: [ + .init( + text: "let foo = Foo()", + diff: .mutated(changes: [ + .init(offset: 0, element: "let"), + .init(offset: 10, element: "Foo"), + ]) + ), + .init( + text: "foo.bar()", + diff: .mutated(changes: [ + .init(offset: 6, element: "r"), + ]) + ), + ] + ), + .init( + oldSnippet: [.init(text: "// divider a")], + newSnippet: [.init(text: "// divider a")] + ), + .init( + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + ),], + newSnippet: [] + ), + .init( + oldSnippet: [.init(text: "// divider b")], + newSnippet: [.init(text: "// divider b")] + ), + .init( + oldSnippet: [.init( + text: "print(foo)", + diff: .mutated(changes: [ + .init(offset: 0, element: "print(foo)"), + ]) + )], + newSnippet: [] + ), + .init( + oldSnippet: [.init(text: "// divider c")], + newSnippet: [.init(text: "// divider c")] + ), + .init( + oldSnippet: [ + .init( + text: "func bar() {", + diff: .mutated(changes: []) + ), + .init( + text: " print(foo)", + diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")]) + ), + .init( + text: "}", + diff: .mutated(changes: [.init(offset: 0, element: "}")]) + ), + ], + newSnippet: [ + .init( + text: "func bar() {}", + diff: .mutated(changes: [ + .init(offset: 12, element: "}"), + ]) + ), + ] + ), + ]) + ) + } } From 5e42dc9c5e46a7619d608ebe0fb6e56fd376c3d2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jul 2024 23:03:17 +0800 Subject: [PATCH 030/116] Add TextCursorTracker environment key --- .../CodeBlockSuggestionPanel.swift | 2 +- .../SuggestionWidget/TextCursorTracker.swift | 17 +++++++++++++++++ .../WidgetWindowsController.swift | 5 ++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 5eac6150..e194acf8 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -31,7 +31,7 @@ public struct PresentingCodeSuggestion: Equatable { struct CodeBlockSuggestionPanel: View { let suggestion: PresentingCodeSuggestion - @Environment(TextCursorTracker.self) var textCursorTracker + @Environment(\.textCursorTracker) var textCursorTracker @Environment(\.colorScheme) var colorScheme @AppStorage(\.suggestionCodeFont) var codeFont @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode diff --git a/Core/Sources/SuggestionWidget/TextCursorTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift index 574949b0..f73511a7 100644 --- a/Core/Sources/SuggestionWidget/TextCursorTracker.swift +++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift @@ -3,6 +3,7 @@ import Foundation import Perception import SuggestionBasic import XcodeInspector +import SwiftUI /// A passive tracker that observe the changes of the source editor content. @Perceptible @@ -38,8 +39,13 @@ final class TextCursorTracker { deinit { eventObservationTask?.cancel() } + + var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } private func observeAppChange() { + if isPreview { return } editorObservationTask = [] Task { await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in @@ -52,6 +58,7 @@ final class TextCursorTracker { } private func observeAXNotifications(_ editor: SourceEditor) { + if isPreview { return } eventObservationTask?.cancel() let content = editor.getLatestEvaluatedContent() Task { @MainActor in @@ -70,3 +77,13 @@ final class TextCursorTracker { } } +struct TextCursorTrackerEnvironmentKey: EnvironmentKey { + static var defaultValue: TextCursorTracker = .init() +} + +extension EnvironmentValues { + var textCursorTracker: TextCursorTracker { + get { self[TextCursorTrackerEnvironmentKey.self] } + set { self[TextCursorTrackerEnvironmentKey.self] = newValue } + } +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 7caf1336..36039bfc 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -651,7 +651,6 @@ public final class WidgetWindows { let store: StoreOf let chatTabPool: ChatTabPool weak var controller: WidgetWindowsController? - let cursorPositionTracker = TextCursorTracker() // you should make these window `.transient` so they never show up in the mission control. @@ -721,7 +720,7 @@ public final class WidgetWindows { state: \.sharedPanelState, action: \.sharedPanel ) - ).environment(cursorPositionTracker) + ) ) it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in @@ -754,7 +753,7 @@ public final class WidgetWindows { state: \.suggestionPanelState, action: \.suggestionPanel ) - ).environment(cursorPositionTracker) + ) ) it.canBecomeKeyChecker = { false } it.setIsVisible(true) From ca93a52666cd6f528b8d9f09074b892ed678efcd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jul 2024 23:04:13 +0800 Subject: [PATCH 031/116] Add fields to PresentingCodeSuggestion --- Core/Sources/Service/GUI/WidgetDataSource.swift | 4 +++- Core/Sources/SuggestionWidget/SharedPanelView.swift | 3 ++- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 8 +++++++- .../SuggestionWidget/SuggestionWidgetDataSource.swift | 3 ++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 5fb5e9d8..7b7ac4d4 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -24,7 +24,9 @@ extension WidgetDataSource: SuggestionWidgetDataSource { language: filespace.language.rawValue, startLineIndex: suggestion.position.line, suggestionCount: filespace.suggestions.count, - currentSuggestionIndex: filespace.suggestionIndex + currentSuggestionIndex: filespace.suggestionIndex, + replacingRange: suggestion.range, + descriptions: suggestion.descriptions ) } } diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 3e91b903..26219417 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -163,7 +163,8 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { language: "objective-c", startLineIndex: 8, suggestionCount: 2, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .zero ) ), colorScheme: .dark, diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index e194acf8..fb5075e6 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -13,19 +13,25 @@ public struct PresentingCodeSuggestion: Equatable { public var startLineIndex: Int public var suggestionCount: Int public var currentSuggestionIndex: Int + public var replacingRange: CursorRange + public var descriptions: [CodeSuggestion.Description] public init( code: String, language: String, startLineIndex: Int, suggestionCount: Int, - currentSuggestionIndex: Int + currentSuggestionIndex: Int, + replacingRange: CursorRange, + descriptions: [CodeSuggestion.Description] = [] ) { self.code = code self.language = language self.startLineIndex = startLineIndex self.suggestionCount = suggestionCount self.currentSuggestionIndex = currentSuggestionIndex + self.replacingRange = replacingRange + self.descriptions = descriptions } } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index 9f14d8c1..7275b670 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -17,7 +17,8 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { language: "swift", startLineIndex: 1, suggestionCount: 3, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .zero ) } } From 02e9c06c565e11c2211306675355b0c99625eadc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jul 2024 23:04:38 +0800 Subject: [PATCH 032/116] Restructure AsyncCodeBlock --- .../CodeBlockSuggestionPanel.swift | 78 ++- .../SharedUIComponents/AsyncCodeBlock.swift | 475 ++++++++++++------ 2 files changed, 378 insertions(+), 175 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index fb5075e6..45107d27 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -169,13 +169,10 @@ struct CodeBlockSuggestionPanel: View { VStack(spacing: 0) { CustomScrollView { WithPerceptionTracking { - let diffResult = Self.diff( - suggestion: suggestion, - textCursorTracker: textCursorTracker - ) - + let (code, originalCode, dimmedCharacterCount) = extractCode() AsyncCodeBlock( - code: suggestion.code, + code: code, + originalCode: originalCode, language: suggestion.language, startLineIndex: suggestion.startLineIndex, scenario: "suggestion", @@ -195,7 +192,7 @@ struct CodeBlockSuggestionPanel: View { } return nil }(), - dimmedCharacterCount: 0 + dimmedCharacterCount: dimmedCharacterCount ) .frame(maxWidth: .infinity) .background({ () -> Color in @@ -228,21 +225,55 @@ struct CodeBlockSuggestionPanel: View { } } - struct DiffResult { - var dimmedRanges: [Range] - var mutatedRanges: [Range] - var deletedRanges: [Range] - } - @MainActor - static func diff( - suggestion: PresentingCodeSuggestion, - textCursorTracker: TextCursorTracker - ) -> DiffResult { - let typedContentCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line + func extractCode() -> ( + code: String, + originalCode: String, + dimmedCharacterCount: AsyncCodeBlock.DimmedCharacterCount + ) { + let range = suggestion.replacingRange + let codeInRange = EditorInformation.code(in: textCursorTracker.content.lines, inside: range) + let leftover = { + if range.end.line >= 0, range.end.line < textCursorTracker.content.lines.endIndex { + let lastLine = textCursorTracker.content.lines[range.end.line] + if range.end.character < lastLine.utf16.count { + let startIndex = lastLine.utf16.index( + lastLine.utf16.startIndex, + offsetBy: range.end.character + ) + let leftover = String(lastLine.utf16.suffix(from: startIndex)) + return leftover ?? "" + } + } + return "" + }() + + let prefix = { + if range.start.line >= 0, range.start.line < textCursorTracker.content.lines.endIndex { + let firstLine = textCursorTracker.content.lines[range.start.line] + if range.start.character < firstLine.utf16.count { + let endIndex = firstLine.utf16.index( + firstLine.utf16.startIndex, + offsetBy: range.start.character + ) + let prefix = String(firstLine.utf16.prefix(upTo: endIndex)) + return prefix ?? "" + } + } + return "" + }() + + let code = prefix + suggestion.code + leftover + + let typedCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line ? textCursorTracker.cursorPosition.character : 0 - return .init(dimmedRanges: [], mutatedRanges: [], deletedRanges: []) + + return ( + code, + codeInRange.code, + .init(prefix: typedCount, suffix: leftover.utf16.count) + ) } } @@ -261,7 +292,8 @@ struct CodeBlockSuggestionPanel: View { language: "swift", startLineIndex: 8, suggestionCount: 2, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .outOfScope ), suggestionDisplayCompactMode: .init( wrappedValue: false, "suggestionDisplayCompactMode", @@ -289,7 +321,8 @@ struct CodeBlockSuggestionPanel: View { language: "swift", startLineIndex: 8, suggestionCount: 2, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .outOfScope ), suggestionDisplayCompactMode: .init( wrappedValue: true, "suggestionDisplayCompactMode", @@ -315,7 +348,8 @@ struct CodeBlockSuggestionPanel: View { language: "objective-c", startLineIndex: 8, suggestionCount: 2, - currentSuggestionIndex: 0 + currentSuggestionIndex: 0, + replacingRange: .outOfScope )) .preferredColorScheme(.light) .frame(width: 450, height: 400) diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index c38d8373..38903d2e 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -1,158 +1,45 @@ +import CodeDiff import DebounceFunction import Foundation import Perception import SwiftUI public struct AsyncCodeBlock: View { - @Perceptible - class Storage { - static let queue = DispatchQueue( - label: "code-block-highlight", - qos: .userInteractive, - attributes: .concurrent - ) - - var dimmedCharacterCount: Int = 0 - var code: String? - private var highlightedCode = [NSAttributedString]() - private var foregroundColor: Color = .primary - private(set) var commonPrecedingSpaceCount = 0 - var highlightedContent: [NSAttributedString] { - var highlightedCode = highlightedCode - if dimmedCharacterCount > commonPrecedingSpaceCount, - let firstLine = highlightedCode.first - { - let dimmedCount = dimmedCharacterCount - commonPrecedingSpaceCount - let mutable = NSMutableAttributedString(attributedString: firstLine) - let targetRange = NSRange( - location: 0, - length: min(firstLine.length, max(0, dimmedCount)) - ) - mutable.enumerateAttribute( - .foregroundColor, - in: NSRange(location: 0, length: firstLine.length) - ) { value, range, _ in - guard let color = value as? NSColor else { return } - let opacity = max(0.1, color.alphaComponent * 0.4) - if targetRange.upperBound >= range.upperBound { - mutable.addAttribute( - .foregroundColor, - value: color.withAlphaComponent(opacity), - range: range - ) - } else { - let intersection = NSIntersectionRange(targetRange, range) - guard !(intersection.length == 0) else { return } - let rangeA = intersection - mutable.addAttribute( - .foregroundColor, - value: color.withAlphaComponent(opacity), - range: rangeA - ) - - let rangeB = NSRange( - location: intersection.upperBound, - length: range.upperBound - intersection.upperBound - ) - mutable.addAttribute( - .foregroundColor, - value: color, - range: rangeB - ) - } - } - - highlightedCode[0] = mutable - } - return highlightedCode - } - - @PerceptionIgnored private var debounceFunction: DebounceFunction? - @PerceptionIgnored private var highlightTask: Task? - - init() { - debounceFunction = .init(duration: 0.1, block: { view in - self.highlight(for: view) - }) - } - - func highlight(debounce: Bool, for view: AsyncCodeBlock) { - if debounce { - Task { @MainActor in await debounceFunction?(view) } - } else { - highlight(for: view) - } - } - - private func highlight(for view: AsyncCodeBlock) { - highlightTask?.cancel() - let code = self.code ?? view.code - let language = view.language - let scenario = view.scenario - let brightMode = view.colorScheme != .dark - let droppingLeadingSpaces = view.droppingLeadingSpaces - let font = view.font - foregroundColor = view.foregroundColor - - if highlightedCode.isEmpty { - let content = CodeHighlighting.convertToCodeLines( - .init(string: code), - middleDotColor: brightMode - ? NSColor.black.withAlphaComponent(0.1) - : NSColor.white.withAlphaComponent(0.1), - droppingLeadingSpaces: droppingLeadingSpaces, - replaceSpacesWithMiddleDots: true - ) - highlightedCode = content.code - commonPrecedingSpaceCount = content.commonLeadingSpaceCount - } - - highlightTask = Task { - let result = await withUnsafeContinuation { continuation in - Self.queue.async { - let content = CodeHighlighting.highlighted( - code: code, - language: language, - scenario: scenario, - brightMode: brightMode, - droppingLeadingSpaces: droppingLeadingSpaces, - font: font - ) - continuation.resume(returning: content) - } - } - try Task.checkCancellation() - await MainActor.run { - self.highlightedCode = result.0 - self.commonPrecedingSpaceCount = result.1 - } - } - } - } - @State var storage = Storage() @Environment(\.colorScheme) var colorScheme + /// If original code is provided, diff will be generated. + let originalCode: String? + /// The code to present. let code: String + /// The language of the code. let language: String + /// The index of the first line. let startLineIndex: Int + /// The scenario of the code block. let scenario: String + /// The font of the code block. let font: NSFont + /// The default foreground color of the code block. let proposedForegroundColor: Color? - let dimmedCharacterCount: Int + /// The ranges to dim in the code. + let dimmedCharacterCount: DimmedCharacterCount + /// Whether to drop common leading spaces of each line. let droppingLeadingSpaces: Bool public init( code: String, + originalCode: String? = nil, language: String, startLineIndex: Int, scenario: String, font: NSFont, droppingLeadingSpaces: Bool, proposedForegroundColor: Color?, - dimmedCharacterCount: Int + dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0) ) { self.code = code + self.originalCode = originalCode self.startLineIndex = startLineIndex self.language = language self.scenario = scenario @@ -169,7 +56,7 @@ public struct AsyncCodeBlock: View { public var body: some View { WithPerceptionTracking { VStack(spacing: 2) { - let commonPrecedingSpaceCount = storage.commonPrecedingSpaceCount + let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount ForEach(Array(storage.highlightedContent.enumerated()), id: \.0) { item in let (index, attributedString) = item HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -200,49 +87,331 @@ public struct AsyncCodeBlock: View { .padding([.trailing, .top, .bottom]) .onAppear { storage.dimmedCharacterCount = dimmedCharacterCount - storage.highlight(debounce: false, for: self) + storage.highlightStorage.highlight(debounce: false, for: self) + storage.diffStorage.diff(for: self) } .onChange(of: code) { code in - storage.code = code // But why do we need this? Time to learn some SwiftUI! - storage.highlight(debounce: true, for: self) + storage.code = code + storage.highlightStorage.highlight(debounce: true, for: self) + storage.diffStorage.diff(for: self) + } + .onChange(of: originalCode) { originalCode in + storage.originalCode = originalCode + storage.diffStorage.diff(for: self) } .onChange(of: colorScheme) { _ in - storage.highlight(debounce: true, for: self) + storage.highlightStorage.highlight(debounce: true, for: self) } .onChange(of: droppingLeadingSpaces) { _ in - storage.highlight(debounce: true, for: self) + storage.highlightStorage.highlight(debounce: true, for: self) } .onChange(of: scenario) { _ in - storage.highlight(debounce: true, for: self) + storage.highlightStorage.highlight(debounce: true, for: self) } .onChange(of: language) { _ in - storage.highlight(debounce: true, for: self) + storage.highlightStorage.highlight(debounce: true, for: self) } .onChange(of: proposedForegroundColor) { _ in - storage.highlight(debounce: true, for: self) + storage.highlightStorage.highlight(debounce: true, for: self) } .onChange(of: dimmedCharacterCount) { value in storage.dimmedCharacterCount = value } } } +} - static func highlight( - code: String, - language: String, - scenario: String, - colorScheme: ColorScheme, - font: NSFont, - droppingLeadingSpaces: Bool - ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { - return CodeHighlighting.highlighted( - code: code, - language: language, - scenario: scenario, - brightMode: colorScheme != .dark, - droppingLeadingSpaces: droppingLeadingSpaces, - font: font - ) +// MARK: - Storage + +extension AsyncCodeBlock { + static let queue = DispatchQueue( + label: "code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + public struct DimmedCharacterCount: Equatable { + public var prefix: Int + public var suffix: Int + public init(prefix: Int, suffix: Int) { + self.prefix = prefix + self.suffix = suffix + } + } + + @Perceptible + class Storage { + var dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0) + let diffStorage = DiffStorage() + let highlightStorage = HighlightStorage() + + var code: String? { + get { highlightStorage.code } + set { + highlightStorage.code = newValue + diffStorage.code = newValue + } + } + + var originalCode: String? { + get { diffStorage.originalCode } + set { diffStorage.originalCode = newValue } + } + + var highlightedContent: [NSAttributedString] { + let commonPrecedingSpaceCount = highlightStorage.commonPrecedingSpaceCount + let highlightedCode = highlightStorage.highlightedCode + .map(NSMutableAttributedString.init(attributedString:)) + + Self.dim( + highlightedCode, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + dimmedCharacterCount: dimmedCharacterCount + ) + + if let diffResult = diffStorage.diffResult { + Self.presentDiff( + highlightedCode, + commonPrecedingSpaceCount: commonPrecedingSpaceCount, + diffResult: diffResult + ) + } + + return highlightedCode + } + + static func dim( + _ highlightedCode: [NSMutableAttributedString], + commonPrecedingSpaceCount: Int, + dimmedCharacterCount: DimmedCharacterCount + ) { + func dim( + _ line: NSMutableAttributedString, + in targetRange: Range, + opacity: Double + ) { + let targetRange = NSRange(targetRange, in: line.string) + line.enumerateAttribute( + .foregroundColor, + in: NSRange(location: 0, length: line.length) + ) { value, range, _ in + guard let color = value as? NSColor else { return } + let opacity = max(0.1, color.alphaComponent * opacity) + let intersection = NSIntersectionRange(targetRange, range) + guard !(intersection.length == 0) else { return } + let rangeA = intersection + line.addAttribute( + .foregroundColor, + value: color.withAlphaComponent(opacity), + range: rangeA + ) + + let rangeB = NSRange( + location: intersection.upperBound, + length: range.upperBound - intersection.upperBound + ) + line.addAttribute( + .foregroundColor, + value: color, + range: rangeB + ) + } + } + + if dimmedCharacterCount.prefix > commonPrecedingSpaceCount, + let firstLine = highlightedCode.first + { + let dimmedCount = dimmedCharacterCount.prefix - commonPrecedingSpaceCount + let startIndex = firstLine.string.startIndex + let endIndex = firstLine.string.utf16.index( + startIndex, + offsetBy: min(firstLine.length, max(0, dimmedCount)), + limitedBy: firstLine.string.endIndex + ) ?? firstLine.string.endIndex + if endIndex > startIndex { + dim(firstLine, in: startIndex..? + @PerceptionIgnored private var diffTask: Task? + + init() { + debounceFunction = .init(duration: 0.1, block: { view in + self.diff(for: view) + }) + } + + func diff(for view: AsyncCodeBlock) { + Task { @MainActor in await debounceFunction?(view) } + } + + private func performDiff(for view: AsyncCodeBlock) { + diffTask?.cancel() + let code = code ?? view.code + guard let originalCode = originalCode ?? view.originalCode else { + diffResult = nil + return + } + + diffTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let result = CodeDiff().diff(snippet: code, from: originalCode) + continuation.resume(returning: result) + } + } + try Task.checkCancellation() + await MainActor.run { + diffResult = result + } + } + } } + + @Perceptible + class HighlightStorage { + private(set) var highlightedCode = [NSAttributedString]() + private(set) var commonPrecedingSpaceCount = 0 + + @PerceptionIgnored var code: String? + @PerceptionIgnored private var foregroundColor: Color = .primary + @PerceptionIgnored private var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() { + debounceFunction = .init(duration: 0.1, block: { view in + self.highlight(for: view) + }) + } + + func highlight(debounce: Bool, for view: AsyncCodeBlock) { + if debounce { + Task { @MainActor in await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + private func highlight(for view: AsyncCodeBlock) { + highlightTask?.cancel() + let code = self.code ?? view.code + let language = view.language + let scenario = view.scenario + let brightMode = view.colorScheme != .dark + let droppingLeadingSpaces = view.droppingLeadingSpaces + foregroundColor = view.foregroundColor + + if highlightedCode.isEmpty { + let content = CodeHighlighting.convertToCodeLines( + .init(string: code), + middleDotColor: brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1), + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: true + ) + highlightedCode = content.code + commonPrecedingSpaceCount = content.commonLeadingSpaceCount + } + + highlightTask = Task { + let result = await withUnsafeContinuation { continuation in + AsyncCodeBlock.queue.async { + let font = view.font + let content = CodeHighlighting.highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + continuation.resume(returning: content) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlightedCode = result.0 + self.commonPrecedingSpaceCount = result.1 + } + } + } + } +} + +#Preview("Single Line Suggestion") { + AsyncCodeBlock( + code: " let foo = Bar()", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0) + ) + .frame(width: 400, height: 100) +} + +#Preview("Single Line Suggestion / Appending Suffix") { + AsyncCodeBlock( + code: " let foo = Bar() // comment", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 11) + ) + .frame(width: 400, height: 100) +} + +#Preview("Multiple Line Suggestion") { + AsyncCodeBlock( + code: " let foo = Bar()\n print(foo)", + originalCode: " var foo // comment", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0) + ) + .frame(width: 400, height: 100) +} + +#Preview("Updating Content") { + EmptyView() } From b967471c3cdc5f56d4f045374a7baea35a5ab0f6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jul 2024 23:45:54 +0800 Subject: [PATCH 033/116] Present diff in AsyncCodeBlock --- .../SharedUIComponents/AsyncCodeBlock.swift | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 38903d2e..bf144c31 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -250,7 +250,49 @@ extension AsyncCodeBlock { _ highlightedCode: [NSMutableAttributedString], commonPrecedingSpaceCount: Int, diffResult: CodeDiff.SnippetDiff - ) {} + ) { + for (index, mutableString) in highlightedCode.enumerated() { + guard let line = diffResult.line(at: index, in: \.newSnippet) else { continue } + guard case let .mutated(changes) = line.diff, !changes.isEmpty else { continue } + + for change in changes { + if change.offset == 0, + change.element.count - commonPrecedingSpaceCount + == mutableString.string.count + { + // ignore the whole line change + continue + } + + let offset = change.offset - commonPrecedingSpaceCount + let range = NSRange( + location: max(0, offset), + length: max(0, change.element.count + (offset < 0 ? offset : 0)) + ) + mutableString.addAttributes([ + .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), + ], range: range) + } + } + + let lastLineIndex = highlightedCode.endIndex - 1 + if lastLineIndex >= 0 { + if let line = diffResult.line(at: lastLineIndex, in: \.oldSnippet), + case let .mutated(changes) = line.diff, + !changes.isEmpty + { + let lastLine = highlightedCode[lastLineIndex] + let removedSuffix = line.text.suffix(max( + 0, + line.text.count - lastLine.string.count + )) + lastLine.append(.init(string: String(removedSuffix), attributes: [ + .foregroundColor: NSColor.systemRed.withAlphaComponent(0.4), + .backgroundColor: NSColor.systemRed.withAlphaComponent(0.1), + ])) + } + } + } } @Perceptible @@ -259,17 +301,10 @@ extension AsyncCodeBlock { @PerceptionIgnored var originalCode: String? @PerceptionIgnored var code: String? - @PerceptionIgnored private var debounceFunction: DebounceFunction? @PerceptionIgnored private var diffTask: Task? - init() { - debounceFunction = .init(duration: 0.1, block: { view in - self.diff(for: view) - }) - } - func diff(for view: AsyncCodeBlock) { - Task { @MainActor in await debounceFunction?(view) } + performDiff(for: view) } private func performDiff(for view: AsyncCodeBlock) { @@ -412,6 +447,39 @@ extension AsyncCodeBlock { } #Preview("Updating Content") { - EmptyView() + struct UpdateContent: View { + @State var index = 0 + struct Case { + let code: String + let originalCode: String + } + + let cases: [Case] = [ + .init(code: "foo(123)", originalCode: "bar(234)"), + .init(code: "bar(456)", originalCode: "baz(567)"), + ] + + var body: some View { + VStack { + Button("Update") { + index = (index + 1) % cases.count + } + AsyncCodeBlock( + code: cases[index].code, + originalCode: cases[index].originalCode, + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 0, suffix: 0) + ) + } + } + } + + return UpdateContent() + .frame(width: 400, height: 200) } From 045dd424371bc8da8eb6d48cfb45426dd0d67ad2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 00:54:08 +0800 Subject: [PATCH 034/116] Remove the traliing new line --- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 45107d27..b1a49520 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -241,7 +241,10 @@ struct CodeBlockSuggestionPanel: View { lastLine.utf16.startIndex, offsetBy: range.end.character ) - let leftover = String(lastLine.utf16.suffix(from: startIndex)) + var leftover = String(lastLine.utf16.suffix(from: startIndex)) + if leftover?.last?.isNewline ?? false { + leftover?.removeLast(1) + } return leftover ?? "" } } From a0ece8785f40794f8a8324cfb6730467e5fc100c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 01:24:53 +0800 Subject: [PATCH 035/116] Adjust service --- .../RealtimeSuggestionController.swift | 4 +- Core/Sources/Service/Service.swift | 66 ++++++++++++------- .../PseudoCommandHandler.swift | 11 ++-- .../WindowBaseCommandHandler.swift | 4 +- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 06d6f522..2d00c960 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -163,10 +163,10 @@ public actor RealtimeSuggestionController { func cancelInFlightTasks(excluding: Task? = nil) async { inflightPrefetchTask?.cancel() - + let workspaces = await Service.shared.workspacePool.workspaces // cancel in-flight tasks await withTaskGroup(of: Void.self) { group in - for (_, workspace) in Service.shared.workspacePool.workspaces { + for (_, workspace) in workspaces { group.addTask { await workspace.cancelInFlightRealtimeSuggestionRequests() } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 5cec4a8d..c0d979e8 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -25,12 +25,14 @@ import ProService /// The running extension service. public final class Service { + @MainActor public static let shared = Service() - let workspacePool = WorkspacePool() + @Dependency(\.workspacePool) var workspacePool @MainActor - public let guiController = GraphicalUserInterfaceController() - public let realtimeSuggestionController = RealtimeSuggestionController() + public let guiController: GraphicalUserInterfaceController + public let commandHandler: CommandHandler + public let realtimeSuggestionController: RealtimeSuggestionController public let scheduledCleaner: ScheduledCleaner let globalShortcutManager: GlobalShortcutManager let keyBindingManager: KeyBindingManager @@ -43,15 +45,47 @@ public final class Service { @Dependency(\.toast) var toast var cancellable = Set() + @MainActor private init() { - WorkspacePoolDependencyKey.liveValue = workspacePool - CommandHandlerDependencyKey.liveValue = PseudoCommandHandler() + @Dependency(\.workspacePool) var workspacePool + let commandHandler = PseudoCommandHandler() + self.commandHandler = commandHandler + + func setup(_ operation: () -> R) -> R { + withDependencies { values in + values.commandHandler = commandHandler + } operation: { + operation() // + } + } + + realtimeSuggestionController = setup { .init() } + scheduledCleaner = setup { .init() } + let guiController = setup { GraphicalUserInterfaceController() } + self.guiController = guiController + globalShortcutManager = setup { .init(guiController: guiController) } + + keyBindingManager = setup { + .init( + workspacePool: workspacePool, + acceptSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion() } + }, + dismissSuggestion: { + Task { await PseudoCommandHandler().dismissSuggestion() } + } + ) + } + + #if canImport(ProService) + proService = setup { ProService() } + #endif BuiltinExtensionManager.shared.setupExtensions([ GitHubCopilotExtension(workspacePool: workspacePool), CodeiumExtension(workspacePool: workspacePool), ]) - scheduledCleaner = .init() + workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } } @@ -64,21 +98,6 @@ public final class Service { workspacePool.registerPlugin { BuiltinExtensionWorkspacePlugin(workspace: $0) } - - globalShortcutManager = .init(guiController: guiController) - keyBindingManager = .init( - workspacePool: workspacePool, - acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } - }, - dismissSuggestion: { - Task { await PseudoCommandHandler().dismissSuggestion() } - } - ) - - #if canImport(ProService) - proService = ProService() - #endif scheduledCleaner.service = self } @@ -101,9 +120,10 @@ public final class Service { .removeDuplicates() .filter { $0 != .init(fileURLWithPath: "/") } .compactMap { $0 } - .sink { [weak self] fileURL in + .sink { fileURL in Task { - try await self?.workspacePool + @Dependency(\.workspacePool) var workspacePool + return try await workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) } }.store(in: &cancellable) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 3ff8df31..d242d758 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -326,18 +326,17 @@ struct PseudoCommandHandler: CommandHandler { func dismissSuggestion() async { guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } + PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) guard let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } - await filespace.reset() - PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) } func openChat(forceDetach: Bool) { switch UserDefaults.shared.value(for: \.openChatMode) { case .chatPanel: - let store = Service.shared.guiController.store Task { @MainActor in + let store = Service.shared.guiController.store await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() store.send(.openChatPanel(forceDetach: forceDetach)) } @@ -360,8 +359,8 @@ struct PseudoCommandHandler: CommandHandler { if openInApp { #if canImport(BrowserChatTab) - let store = Service.shared.guiController.store Task { @MainActor in + let store = Service.shared.guiController.store await store.send(.createAndSwitchToChatTabIfNeededMatching( check: { func match(_ tabURL: URL?) -> Bool { @@ -386,8 +385,8 @@ struct PseudoCommandHandler: CommandHandler { } } case .codeiumChat: - let store = Service.shared.guiController.store Task { @MainActor in + let store = Service.shared.guiController.store await store.send( .createAndSwitchToChatTabIfNeededMatching( check: { $0 is CodeiumChatTab }, @@ -400,7 +399,7 @@ struct PseudoCommandHandler: CommandHandler { } func sendChatMessage(_ message: String) async { - let store = Service.shared.guiController.store + let store = await Service.shared.guiController.store await store.send(.sendCustomCommandToActiveChat(CustomCommand( commandId: "", name: "", diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index eb58241b..c267cdf7 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -178,7 +178,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() - let store = Service.shared.guiController.store + let store = await Service.shared.guiController.store if let promptToCode = store.state.promptToCodeGroup.activePromptToCode { if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { @@ -388,7 +388,7 @@ extension WindowBaseCommandHandler { ) }() as (String, CursorRange) - let store = Service.shared.guiController.store + let store = await Service.shared.guiController.store let customCommandTemplateProcessor = CustomCommandTemplateProcessor() From 8630be41c681d0ea893a8ef286ed88a786e71d9f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 17:35:37 +0800 Subject: [PATCH 036/116] Fix that suggestion was not invalidated when it doesn't match the typed content --- ...FilespaceSuggestionInvalidationTests.swift | 88 ++++++++++++++----- ExtensionService/AppDelegate.swift | 1 + .../SharedUIComponents/AsyncCodeBlock.swift | 3 + .../Filespace+SuggestionService.swift | 65 ++++++++++++-- 4 files changed, 130 insertions(+), 27 deletions(-) diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index fe8d0a1f..b12550f5 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -38,7 +38,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 4) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false // TODO: What ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -55,7 +56,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 4) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -72,7 +74,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 4) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -89,7 +92,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 2) + cursorPosition: .init(line: 1, character: 2), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -109,12 +113,37 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 3) + cursorPosition: .init(line: 1, character: 3), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion XCTAssertNotNil(suggestion) } + + func test_typing_not_according_to_suggestion_should_invalidate() async throws { + let lines = ["\n", "hello ma\n", "\n"] + let filespace = try await prepare( + lines: lines, + suggestionText: "hello man", + cursorPosition: .init(line: 1, character: 8), + range: .init(startPair: (1, 0), endPair: (1, 8)) + ) + let wasValid = await filespace.validateSuggestions( + lines: lines, + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false + ) + let isValid = await filespace.validateSuggestions( + lines: ["\n", "hello mat\n", "\n"], + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false + ) + XCTAssertTrue(wasValid) + XCTAssertFalse(isValid) + let suggestion = filespace.presentingSuggestion + XCTAssertNil(suggestion) + } func test_text_cursor_moved_to_another_line_should_invalidate() async throws { let lines = ["\n", "hell\n", "\n"] @@ -126,7 +155,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 2, character: 0) + cursorPosition: .init(line: 2, character: 0), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -143,7 +173,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 100, character: 4) + cursorPosition: .init(line: 100, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -159,7 +190,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: ["\n", "helo\n", "\n"], - cursorPosition: .init(line: 1, character: 4) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -175,7 +207,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: ["\n", "helo\n", "\n"], - cursorPosition: .init(line: 1, character: 100) + cursorPosition: .init(line: 1, character: 100), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -192,11 +225,13 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let wasValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 8) + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false ) let isValid = await filespace.validateSuggestions( lines: ["\n", "hello man\n", "\n"], - cursorPosition: .init(line: 1, character: 9) + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(wasValid) XCTAssertFalse(isValid) @@ -215,11 +250,13 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let wasValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 12) + cursorPosition: .init(line: 1, character: 12), + alwaysTrueIfCursorNotMoved: false ) let isValid = await filespace.validateSuggestions( lines: ["\n", "hello mπŸŽ†πŸŽ†an\n", "\n"], - cursorPosition: .init(line: 1, character: 13) + cursorPosition: .init(line: 1, character: 13), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(wasValid) XCTAssertFalse(isValid) @@ -238,11 +275,13 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let wasValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 8) + cursorPosition: .init(line: 1, character: 8), + alwaysTrueIfCursorNotMoved: false ) let isValid = await filespace.validateSuggestions( lines: ["\n", "hello man!!!!!\n", "\n"], - cursorPosition: .init(line: 1, character: 9) + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(wasValid) XCTAssertFalse(isValid) @@ -260,7 +299,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 9) + cursorPosition: .init(line: 1, character: 9), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -278,7 +318,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 13) + cursorPosition: .init(line: 1, character: 13), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -296,7 +337,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 1, character: 4) + cursorPosition: .init(line: 1, character: 4), + alwaysTrueIfCursorNotMoved: false ) XCTAssertFalse(isValid) let suggestion = filespace.presentingSuggestion @@ -313,7 +355,8 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 0, character: 15) + cursorPosition: .init(line: 0, character: 15), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion @@ -325,12 +368,13 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { let filespace = try await prepare( lines: lines, suggestionText: "hello world !!!", - cursorPosition: .init(line: 0, character: 15), - range: .init(startPair: (0, 0), endPair: (0, 15)) + cursorPosition: .init(line: 0, character: 18), + range: .init(startPair: (0, 0), endPair: (0, 18)) ) let isValid = await filespace.validateSuggestions( lines: lines, - cursorPosition: .init(line: 0, character: 18) + cursorPosition: .init(line: 0, character: 18), + alwaysTrueIfCursorNotMoved: false ) XCTAssertTrue(isValid) let suggestion = filespace.presentingSuggestion diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index c63b9a77..1d107672 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -18,6 +18,7 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + @MainActor let service = Service.shared var statusBarItem: NSStatusItem! var xpcController: XPCController? diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index bf144c31..c5024c06 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -269,6 +269,9 @@ extension AsyncCodeBlock { location: max(0, offset), length: max(0, change.element.count + (offset < 0 ? offset : 0)) ) + if range.location + range.length > mutableString.length { + continue + } mutableString.addAttributes([ .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), ], range: range) diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 1ccbcf02..f2f6badd 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -65,9 +65,14 @@ public extension Filespace { /// - Parameters: /// - lines: lines of the file /// - cursorPosition: cursor position + /// - alwaysTrueIfCursorNotMoved: for unit tests /// - Returns: `true` if the suggestion is still valid @WorkspaceActor - func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + func validateSuggestions( + lines: [String], + cursorPosition: CursorPosition, + alwaysTrueIfCursorNotMoved: Bool = true + ) -> Bool { guard let presentingSuggestion else { return false } let snapshot = self[keyPath: \.suggestionSourceSnapshot] if snapshot.cursorPosition == .outOfScope { return false } @@ -76,7 +81,8 @@ public extension Filespace { presentingSuggestion, snapshot: snapshot, lines: lines, - cursorPosition: cursorPosition + cursorPosition: cursorPosition, + alwaysTrueIfCursorNotMoved: alwaysTrueIfCursorNotMoved ) else { reset() resetSnapshot() @@ -92,8 +98,13 @@ extension Filespace { _ suggestion: CodeSuggestion, snapshot: FilespaceSuggestionSnapshot, lines: [String], - cursorPosition: CursorPosition + cursorPosition: CursorPosition, + // For test + alwaysTrueIfCursorNotMoved: Bool = true ) -> Bool { + // cursor is not even moved during the generation. + if alwaysTrueIfCursorNotMoved, cursorPosition == suggestion.position { return true } + // cursor has moved to another line if cursorPosition.line != suggestion.position.line { return false } @@ -101,7 +112,17 @@ extension Filespace { guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { return false } let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending - let suggestionLines = suggestion.text.split(whereSeparator: \.isNewline) + let suggestionLines = suggestion.text.breakLines(appendLineBreakToLastLine: true) + + if Self.validateThatIsNotTypingSuggestion( + suggestion, + snapshot: snapshot, + lines: lines, + suggestionLines: suggestionLines, + cursorPosition: cursorPosition + ) { + return false + } // if the line will not change after accepting the suggestion if Self.validateThatSuggestionMakeNoDifferent( @@ -120,10 +141,44 @@ extension Filespace { return true } + static func validateThatIsNotTypingSuggestion( + _ suggestion: CodeSuggestion, + snapshot: FilespaceSuggestionSnapshot, + lines: [String], + suggestionLines: [String], + cursorPosition: CursorPosition + ) -> Bool { + let lineIndex = suggestion.range.start.line + let typeStart = suggestion.position.character + let cursorColumn = cursorPosition.character + let suggestionStart = max( + 0, + suggestion.position.character - suggestion.range.start.character + ) + func contentBeforeCursor( + _ string: String, + start: Int + ) -> ArraySlice { + if start >= cursorColumn { return [] } + let elements = Array(string.utf16) + guard start >= 0, start < elements.endIndex else { return [] } + let endIndex = min(elements.endIndex, cursorColumn) + return elements[start..= 0, lineIndex < lines.endIndex else { return false } + let editingLine = lines[lineIndex] + let suggestionFirstLine = suggestionLines.first ?? "" + + let typed = contentBeforeCursor(editingLine, start: typeStart) + let expectedTyped = contentBeforeCursor(suggestionFirstLine, start: suggestionStart) + return typed != expectedTyped + } + static func validateThatSuggestionMakeNoDifferent( _ suggestion: CodeSuggestion, lines: [String], - suggestionLines: [Substring] + suggestionLines: [String] ) -> Bool { var editingRange = suggestion.range let startLine = max(0, editingRange.start.line) From 6f0ad4353037d15a3f5193698b95baa0a041aa60 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 18:02:43 +0800 Subject: [PATCH 037/116] Fix that a wrong suffix was append to the suggestion --- .../Service/GUI/WidgetDataSource.swift | 1 + .../SuggestionService/SuggestionService.swift | 11 +++++++++- .../SuggestionWidget/SharedPanelView.swift | 3 ++- .../CodeBlockSuggestionPanel.swift | 20 +++++++++++++------ .../SuggestionWidgetDataSource.swift | 3 ++- .../SuggestionBasic/CodeSuggestion.swift | 4 ++++ 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 7b7ac4d4..ae1b6371 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -26,6 +26,7 @@ extension WidgetDataSource: SuggestionWidgetDataSource { suggestionCount: filespace.suggestions.count, currentSuggestionIndex: filespace.suggestionIndex, replacingRange: suggestion.range, + replacingLines: suggestion.replacingLines, descriptions: suggestion.descriptions ) } diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 335f0c83..35414692 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -84,7 +84,16 @@ public extension SuggestionService { } } - return try await getSuggestion(request, workspaceInfo) + var result = try await getSuggestion(request, workspaceInfo) + if !request.lines.isEmpty { + for index in result.indices { + let range = result[index].range + let lowerBound = max(0, range.start.line) + let upperBound = max(lowerBound, min(request.lines.count - 1, range.end.line)) + result[index].replacingLines = Array(request.lines[lowerBound...upperBound]) + } + } + return result } catch let error as SuggestionServiceError { throw error } catch { diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index 26219417..d65995d4 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -164,7 +164,8 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { startLineIndex: 8, suggestionCount: 2, currentSuggestionIndex: 0, - replacingRange: .zero + replacingRange: .zero, + replacingLines: [""] ) ), colorScheme: .dark, diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index b1a49520..495a64cf 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -14,6 +14,7 @@ public struct PresentingCodeSuggestion: Equatable { public var suggestionCount: Int public var currentSuggestionIndex: Int public var replacingRange: CursorRange + public var replacingLines: [String] public var descriptions: [CodeSuggestion.Description] public init( @@ -23,6 +24,7 @@ public struct PresentingCodeSuggestion: Equatable { suggestionCount: Int, currentSuggestionIndex: Int, replacingRange: CursorRange, + replacingLines: [String], descriptions: [CodeSuggestion.Description] = [] ) { self.code = code @@ -31,6 +33,7 @@ public struct PresentingCodeSuggestion: Equatable { self.suggestionCount = suggestionCount self.currentSuggestionIndex = currentSuggestionIndex self.replacingRange = replacingRange + self.replacingLines = replacingLines self.descriptions = descriptions } } @@ -231,8 +234,10 @@ struct CodeBlockSuggestionPanel: View { originalCode: String, dimmedCharacterCount: AsyncCodeBlock.DimmedCharacterCount ) { - let range = suggestion.replacingRange - let codeInRange = EditorInformation.code(in: textCursorTracker.content.lines, inside: range) + var range = suggestion.replacingRange + range.end = .init(line: range.end.line - range.start.line, character: range.end.character) + range.start = .init(line: 0, character: range.start.character) + let codeInRange = EditorInformation.code(in: suggestion.replacingLines, inside: range) let leftover = { if range.end.line >= 0, range.end.line < textCursorTracker.content.lines.endIndex { let lastLine = textCursorTracker.content.lines[range.end.line] @@ -253,7 +258,7 @@ struct CodeBlockSuggestionPanel: View { let prefix = { if range.start.line >= 0, range.start.line < textCursorTracker.content.lines.endIndex { - let firstLine = textCursorTracker.content.lines[range.start.line] + let firstLine = suggestion.replacingLines[range.start.line] if range.start.character < firstLine.utf16.count { let endIndex = firstLine.utf16.index( firstLine.utf16.startIndex, @@ -296,7 +301,8 @@ struct CodeBlockSuggestionPanel: View { startLineIndex: 8, suggestionCount: 2, currentSuggestionIndex: 0, - replacingRange: .outOfScope + replacingRange: .outOfScope, + replacingLines: [] ), suggestionDisplayCompactMode: .init( wrappedValue: false, "suggestionDisplayCompactMode", @@ -325,7 +331,8 @@ struct CodeBlockSuggestionPanel: View { startLineIndex: 8, suggestionCount: 2, currentSuggestionIndex: 0, - replacingRange: .outOfScope + replacingRange: .outOfScope, + replacingLines: [] ), suggestionDisplayCompactMode: .init( wrappedValue: true, "suggestionDisplayCompactMode", @@ -352,7 +359,8 @@ struct CodeBlockSuggestionPanel: View { startLineIndex: 8, suggestionCount: 2, currentSuggestionIndex: 0, - replacingRange: .outOfScope + replacingRange: .outOfScope, + replacingLines: [] )) .preferredColorScheme(.light) .frame(width: 450, height: 400) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift index 7275b670..2269d095 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -18,7 +18,8 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource { startLineIndex: 1, suggestionCount: 3, currentSuggestionIndex: 0, - replacingRange: .zero + replacingRange: .zero, + replacingLines: [] ) } } diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift index c8cc7347..c9967a49 100644 --- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -22,6 +22,7 @@ public struct CodeSuggestion: Codable, Equatable { text: String, position: CursorPosition, range: CursorRange, + replacingLines: [String] = [], descriptions: [Description] = [], middlewareComments: [String] = [], metadata: [String: String] = [:] @@ -30,6 +31,7 @@ public struct CodeSuggestion: Codable, Equatable { self.position = position self.id = id self.range = range + self.replacingLines = replacingLines self.descriptions = descriptions self.middlewareComments = middlewareComments self.metadata = metadata @@ -53,6 +55,8 @@ public struct CodeSuggestion: Codable, Equatable { /// The range of the original code that should be replaced. public var range: CursorRange /// Descriptions about this code suggestion + @FallbackDecoding public var replacingLines: [String] + /// Descriptions about this code suggestion @FallbackDecoding public var descriptions: [Description] /// A place to store comments inserted by middleware for debugging use. @FallbackDecoding public var middlewareComments: [String] From bf418115a89889b24d85d33a735bf89bcc3e177b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 18:13:43 +0800 Subject: [PATCH 038/116] Update deleted suffix in suggestion --- Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index c5024c06..7e7554be 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -282,14 +282,12 @@ extension AsyncCodeBlock { if lastLineIndex >= 0 { if let line = diffResult.line(at: lastLineIndex, in: \.oldSnippet), case let .mutated(changes) = line.diff, - !changes.isEmpty + changes.count == 1, + let change = changes.last, + change.offset + change.element.count == line.text.count { let lastLine = highlightedCode[lastLineIndex] - let removedSuffix = line.text.suffix(max( - 0, - line.text.count - lastLine.string.count - )) - lastLine.append(.init(string: String(removedSuffix), attributes: [ + lastLine.append(.init(string: String(change.element), attributes: [ .foregroundColor: NSColor.systemRed.withAlphaComponent(0.4), .backgroundColor: NSColor.systemRed.withAlphaComponent(0.1), ])) From ffe1018dcaa37c03f095e272ea0f6f477bb485a3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 20:46:41 +0800 Subject: [PATCH 039/116] Display descriptions in suggestion --- .../CodeBlockSuggestionPanel.swift | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 495a64cf..fc8c9eac 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -167,6 +167,46 @@ struct CodeBlockSuggestionPanel: View { } } + struct Description: View { + var descriptions: [CodeSuggestion.Description] + + var body: some View { + VStack(spacing: 0) { + ForEach(0.. Date: Thu, 18 Jul 2024 22:08:14 +0800 Subject: [PATCH 040/116] Update AsyncCodeBlock --- .../SharedUIComponents/AsyncCodeBlock.swift | 89 +++++++++++++------ 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 7e7554be..302b63f7 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -251,30 +251,63 @@ extension AsyncCodeBlock { commonPrecedingSpaceCount: Int, diffResult: CodeDiff.SnippetDiff ) { - for (index, mutableString) in highlightedCode.enumerated() { - guard let line = diffResult.line(at: index, in: \.newSnippet) else { continue } - guard case let .mutated(changes) = line.diff, !changes.isEmpty else { continue } - - for change in changes { - if change.offset == 0, - change.element.count - commonPrecedingSpaceCount - == mutableString.string.count - { - // ignore the whole line change - continue + let originalCodeIsSingleLine = diffResult.sections.count == 1 + && diffResult.sections[0].oldSnippet.count <= 1 + if !originalCodeIsSingleLine { + for (index, mutableString) in highlightedCode.enumerated() { + guard let line = diffResult.line(at: index, in: \.newSnippet), + case let .mutated(changes) = line.diff, !changes.isEmpty + else { continue } + + for change in changes { + if change.offset == 0, + change.element.count - commonPrecedingSpaceCount + == mutableString.string.count + { + // ignore the whole line change + continue + } + + let offset = change.offset - commonPrecedingSpaceCount + let range = NSRange( + location: max(0, offset), + length: max(0, change.element.count + (offset < 0 ? offset : 0)) + ) + if range.location + range.length > mutableString.length { + continue + } + mutableString.addAttributes([ + .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), + ], range: range) } - - let offset = change.offset - commonPrecedingSpaceCount - let range = NSRange( - location: max(0, offset), - length: max(0, change.element.count + (offset < 0 ? offset : 0)) + } + } else if let firstMutableString = highlightedCode.first, + let oldLine = diffResult.line(at: 0, in: \.oldSnippet), + oldLine.text.count > commonPrecedingSpaceCount + { + // Only highlight the diffs inside the dimmed area + let scopeRange = NSRange( + location: 0, + length: min( + oldLine.text.count - commonPrecedingSpaceCount, + firstMutableString.length ) - if range.location + range.length > mutableString.length { - continue + ) + if let line = diffResult.line(at: 0, in: \.newSnippet), + case let .mutated(changes) = line.diff, !changes.isEmpty + { + for change in changes { + let offset = change.offset - commonPrecedingSpaceCount + let range = NSRange( + location: max(0, offset), + length: max(0, change.element.count + (offset < 0 ? offset : 0)) + ) + guard let limitedRange = limitRange(range, inside: scopeRange) + else { continue } + firstMutableString.addAttributes([ + .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), + ], range: limitedRange) } - mutableString.addAttributes([ - .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2), - ], range: range) } } @@ -288,8 +321,8 @@ extension AsyncCodeBlock { { let lastLine = highlightedCode[lastLineIndex] lastLine.append(.init(string: String(change.element), attributes: [ - .foregroundColor: NSColor.systemRed.withAlphaComponent(0.4), - .backgroundColor: NSColor.systemRed.withAlphaComponent(0.1), + .foregroundColor: NSColor.systemRed.withAlphaComponent(0.5), + .backgroundColor: NSColor.systemRed.withAlphaComponent(0.2), ])) } } @@ -400,6 +433,12 @@ extension AsyncCodeBlock { } } } + + static func limitRange(_ nsRange: NSRange, inside another: NSRange) -> NSRange? { + let intersection = NSIntersectionRange(nsRange, another) + guard intersection.length > 0 else { return nil } + return intersection + } } #Preview("Single Line Suggestion") { @@ -435,7 +474,7 @@ extension AsyncCodeBlock { #Preview("Multiple Line Suggestion") { AsyncCodeBlock( code: " let foo = Bar()\n print(foo)", - originalCode: " var foo // comment", + originalCode: " var foo // comment\n print(bar)", language: "swift", startLineIndex: 10, scenario: "", @@ -456,7 +495,7 @@ extension AsyncCodeBlock { } let cases: [Case] = [ - .init(code: "foo(123)", originalCode: "bar(234)"), + .init(code: "foo(123)\nprint(foo)", originalCode: "bar(234)\nprint(bar)"), .init(code: "bar(456)", originalCode: "baz(567)"), ] From e3bf9466cdcb4ae5180fa8b54b617071c349b730 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 22:59:48 +0800 Subject: [PATCH 041/116] Fix that command handler is not correctly setup --- Core/Sources/Service/Service.swift | 43 +++++------- .../CommandHandler/CommandHandler.swift | 65 ++++++++++++++++++- .../SharedUIComponents/AsyncCodeBlock.swift | 2 +- 3 files changed, 80 insertions(+), 30 deletions(-) diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index c0d979e8..faff4f47 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -49,36 +49,27 @@ public final class Service { private init() { @Dependency(\.workspacePool) var workspacePool let commandHandler = PseudoCommandHandler() + UniversalCommandHandler.shared.commandHandler = commandHandler self.commandHandler = commandHandler - func setup(_ operation: () -> R) -> R { - withDependencies { values in - values.commandHandler = commandHandler - } operation: { - operation() // - } - } - - realtimeSuggestionController = setup { .init() } - scheduledCleaner = setup { .init() } - let guiController = setup { GraphicalUserInterfaceController() } + realtimeSuggestionController = .init() + scheduledCleaner = .init() + let guiController = GraphicalUserInterfaceController() self.guiController = guiController - globalShortcutManager = setup { .init(guiController: guiController) } - - keyBindingManager = setup { - .init( - workspacePool: workspacePool, - acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } - }, - dismissSuggestion: { - Task { await PseudoCommandHandler().dismissSuggestion() } - } - ) - } + globalShortcutManager = .init(guiController: guiController) + + keyBindingManager = .init( + workspacePool: workspacePool, + acceptSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion() } + }, + dismissSuggestion: { + Task { await PseudoCommandHandler().dismissSuggestion() } + } + ) #if canImport(ProService) - proService = setup { ProService() } + proService = ProService() #endif BuiltinExtensionManager.shared.setupExtensions([ @@ -149,7 +140,7 @@ public extension Service { ) { do { #if canImport(ProService) - try Service.shared.proService.handleXPCServiceRequests( + try proService.handleXPCServiceRequests( endpoint: endpoint, requestBody: requestBody, reply: reply diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift index bcdc162d..68735bd4 100644 --- a/Tool/Sources/CommandHandler/CommandHandler.swift +++ b/Tool/Sources/CommandHandler/CommandHandler.swift @@ -36,19 +36,78 @@ public protocol CommandHandler { } public struct CommandHandlerDependencyKey: DependencyKey { - public static var liveValue: CommandHandler = NoopCommandHandler() + public static var liveValue: CommandHandler = UniversalCommandHandler.shared + public static var testValue: CommandHandler = NoopCommandHandler() } public extension DependencyValues { + /// In production, you need to override the command handler globally by setting + /// ``UniversalCommandHandler.shared.commandHandler``. + /// + /// In tests, you can use ``withDependency`` to mock it. var commandHandler: CommandHandler { get { self[CommandHandlerDependencyKey.self] } set { self[CommandHandlerDependencyKey.self] = newValue } } } -struct NoopCommandHandler: CommandHandler { - static let shared: CommandHandler = NoopCommandHandler() +public final class UniversalCommandHandler: CommandHandler { + public static let shared: UniversalCommandHandler = UniversalCommandHandler() + + public var commandHandler: CommandHandler = NoopCommandHandler() + + private init() {} + + public func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async { + await commandHandler.presentSuggestions(suggestions) + } + + public func presentPreviousSuggestion() async { + await commandHandler.presentPreviousSuggestion() + } + + public func presentNextSuggestion() async { + await commandHandler.presentNextSuggestion() + } + public func rejectSuggestions() async { + await commandHandler.rejectSuggestions() + } + + public func acceptSuggestion() async { + await commandHandler.acceptSuggestion() + } + + public func dismissSuggestion() async { + await commandHandler.dismissSuggestion() + } + + public func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { + await commandHandler.generateRealtimeSuggestions(sourceEditor: sourceEditor) + } + + public func openChat(forceDetach: Bool) { + commandHandler.openChat(forceDetach: forceDetach) + } + + public func sendChatMessage(_ message: String) async { + await commandHandler.sendChatMessage(message) + } + + public func acceptPromptToCode() async { + await commandHandler.acceptPromptToCode() + } + + public func handleCustomCommand(_ command: CustomCommand) async { + await commandHandler.handleCustomCommand(command) + } + + public func toast(_ string: String, as type: ToastType) { + commandHandler.toast(string, as: type) + } +} + +struct NoopCommandHandler: CommandHandler { func presentSuggestions(_: [CodeSuggestion]) async {} func presentPreviousSuggestion() async {} func presentNextSuggestion() async {} diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 302b63f7..485e15ed 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -4,7 +4,7 @@ import Foundation import Perception import SwiftUI -public struct AsyncCodeBlock: View { +public struct AsyncCodeBlock: View { // chat: hid @State var storage = Storage() @Environment(\.colorScheme) var colorScheme From 2a36d2e331b9b247a57172489b421c2bf45eaf16 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 23:31:32 +0800 Subject: [PATCH 042/116] Tweak the behavior of activating the app on chat --- .../GraphicalUserInterfaceController.swift | 16 ++++++++------- .../Service/GlobalShortcutManager.swift | 2 +- .../PseudoCommandHandler.swift | 20 ++++++++++++++----- .../PresentInWindowSuggestionPresenter.swift | 11 ++-------- .../FeatureReducers/ChatPanelFeature.swift | 1 - .../SuggestionWidgetController.swift | 12 ----------- .../CommandHandler/CommandHandler.swift | 8 ++++---- 7 files changed, 31 insertions(+), 39 deletions(-) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 849588f4..3428d318 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -55,7 +55,7 @@ struct GUI { enum Action { case start - case openChatPanel(forceDetach: Bool) + case openChatPanel(forceDetach: Bool, activateThisApp: Bool) case createAndSwitchToChatGPTChatTabIfNeeded case createAndSwitchToChatTabIfNeededMatching( check: (any ChatTab) -> Bool, @@ -138,7 +138,7 @@ struct GUI { return .none #endif - case let .openChatPanel(forceDetach): + case let .openChatPanel(forceDetach, activate): return .run { send in await send( .suggestionWidget( @@ -147,7 +147,9 @@ struct GUI { ) await send(.suggestionWidget(.updateKeyWindow(.chatPanel))) - activateThisApp() + if activate { + activateThisApp() + } } case .createAndSwitchToChatGPTChatTabIfNeeded: @@ -199,7 +201,7 @@ struct GUI { let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab { return .run { send in - await send(.openChatPanel(forceDetach: false)) + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) await stopAndHandleCommand(activeTab) } } @@ -211,7 +213,7 @@ struct GUI { { state.chatTabGroup.selectedTabId = chatTab.id return .run { send in - await send(.openChatPanel(forceDetach: false)) + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) await stopAndHandleCommand(chatTab) } } @@ -220,7 +222,7 @@ struct GUI { guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) else { return } await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) - await send(.openChatPanel(forceDetach: false)) + await send(.openChatPanel(forceDetach: false, activateThisApp: false)) if let chatTab = chatTab as? ChatGPTChatTab { await stopAndHandleCommand(chatTab) } @@ -347,7 +349,7 @@ public final class GraphicalUserInterfaceController { suggestionDependency.onOpenChatClicked = { [weak self] in Task { [weak self] in await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() - self?.store.send(.openChatPanel(forceDetach: false)) + self?.store.send(.openChatPanel(forceDetach: false, activateThisApp: true)) } } suggestionDependency.onCustomCommandClicked = { command in diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift index 9620f25a..cc799fc0 100644 --- a/Core/Sources/Service/GlobalShortcutManager.swift +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -28,7 +28,7 @@ final class GlobalShortcutManager { !guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { - guiController.store.send(.openChatPanel(forceDetach: true)) + guiController.store.send(.openChatPanel(forceDetach: true, activateThisApp: true)) } else { guiController.store.send(.toggleWidgetsHotkeyPressed) } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d242d758..23ba7c67 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -332,13 +332,16 @@ struct PseudoCommandHandler: CommandHandler { await filespace.reset() } - func openChat(forceDetach: Bool) { + func openChat(forceDetach: Bool, activateThisApp: Bool = true) { switch UserDefaults.shared.value(for: \.openChatMode) { case .chatPanel: Task { @MainActor in let store = Service.shared.guiController.store await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish() - store.send(.openChatPanel(forceDetach: forceDetach)) + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) } case .browser: let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL) @@ -375,7 +378,10 @@ struct PseudoCommandHandler: CommandHandler { }, kind: .init(BrowserChatTab.urlChatBuilder(url: url)) )).finish() - store.send(.openChatPanel(forceDetach: forceDetach)) + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) } #endif } else { @@ -393,13 +399,17 @@ struct PseudoCommandHandler: CommandHandler { kind: .init(CodeiumChatTab.defaultChatBuilder()) ) ).finish() - store.send(.openChatPanel(forceDetach: forceDetach)) + store.send(.openChatPanel( + forceDetach: forceDetach, + activateThisApp: activateThisApp + )) } } } + @MainActor func sendChatMessage(_ message: String) async { - let store = await Service.shared.guiController.store + let store = Service.shared.guiController.store await store.send(.sendCustomCommandToActiveChat(CustomCommand( commandId: "", name: "", diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift index ce2eb039..7069422b 100644 --- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -42,17 +42,10 @@ struct PresentInWindowSuggestionPresenter { } } - func closeChatRoom(fileURL: URL) { - Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.closeChatRoom() - } - } - func presentChatRoom(fileURL: URL) { Task { @MainActor in - let controller = Service.shared.guiController.widgetController - controller.presentChatRoom() + let controller = Service.shared.guiController + controller.store.send(.openChatPanel(forceDetach: false, activateThisApp: true)) } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 4707c56f..2e02160a 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -166,7 +166,6 @@ public struct ChatPanelFeature { .chatPanelWindow .centerInActiveSpaceIfNeeded() } - activateExtensionService() await send(.focusActiveChatTab) } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index ab15d53b..3c708fbb 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -70,17 +70,5 @@ public extension SuggestionWidgetController { func presentError(_ errorDescription: String) { store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil)))) } - - func presentChatRoom() { - store.send(.chatPanel(.presentChatPanel(forceDetach: false))) - } - - func presentDetachedGlobalChat() { - store.send(.chatPanel(.presentChatPanel(forceDetach: true))) - } - - func closeChatRoom() { -// store.send(.chatPanel(.closeChatPanel)) - } } diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift index 68735bd4..afba1e96 100644 --- a/Tool/Sources/CommandHandler/CommandHandler.swift +++ b/Tool/Sources/CommandHandler/CommandHandler.swift @@ -19,7 +19,7 @@ public protocol CommandHandler { // MARK: Chat - func openChat(forceDetach: Bool) + func openChat(forceDetach: Bool, activateThisApp: Bool) func sendChatMessage(_ message: String) async // MARK: Prompt to Code @@ -86,8 +86,8 @@ public final class UniversalCommandHandler: CommandHandler { await commandHandler.generateRealtimeSuggestions(sourceEditor: sourceEditor) } - public func openChat(forceDetach: Bool) { - commandHandler.openChat(forceDetach: forceDetach) + public func openChat(forceDetach: Bool, activateThisApp: Bool) { + commandHandler.openChat(forceDetach: forceDetach, activateThisApp: activateThisApp) } public func sendChatMessage(_ message: String) async { @@ -115,7 +115,7 @@ struct NoopCommandHandler: CommandHandler { func acceptSuggestion() async {} func dismissSuggestion() async {} func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {} - func openChat(forceDetach: Bool) {} + func openChat(forceDetach: Bool, activateThisApp: Bool) {} func sendChatMessage(_: String) async {} func acceptPromptToCode() async {} func handleCustomCommand(_: CustomCommand) async {} From 5cc5f7abeea28dc9293d6d0e449ceee5a0235173 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 18 Jul 2024 23:37:26 +0800 Subject: [PATCH 043/116] Fix suggetion display suffix --- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index fc8c9eac..8b03409d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -283,8 +283,8 @@ struct CodeBlockSuggestionPanel: View { range.start = .init(line: 0, character: range.start.character) let codeInRange = EditorInformation.code(in: suggestion.replacingLines, inside: range) let leftover = { - if range.end.line >= 0, range.end.line < textCursorTracker.content.lines.endIndex { - let lastLine = textCursorTracker.content.lines[range.end.line] + if range.end.line >= 0, range.end.line < suggestion.replacingLines.endIndex { + let lastLine = suggestion.replacingLines[range.end.line] if range.end.character < lastLine.utf16.count { let startIndex = lastLine.utf16.index( lastLine.utf16.startIndex, @@ -301,7 +301,7 @@ struct CodeBlockSuggestionPanel: View { }() let prefix = { - if range.start.line >= 0, range.start.line < textCursorTracker.content.lines.endIndex { + if range.start.line >= 0, range.start.line < suggestion.replacingLines.endIndex { let firstLine = suggestion.replacingLines[range.start.line] if range.start.character < firstLine.utf16.count { let endIndex = firstLine.utf16.index( From c06556e1441aef12d8b5d3fa92e40b82b46b38d6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 19 Jul 2024 17:31:07 +0800 Subject: [PATCH 044/116] Bump version to 0.34.0 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 4c9957e3..edd55468 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.33.5 -APP_BUILD = 394 +APP_VERSION = 0.34.0 +APP_BUILD = 400 From 785d84f6c821343e82eeeda7efa581f93b66eeb8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 19 Jul 2024 17:41:17 +0800 Subject: [PATCH 045/116] Add model gpt-4o-mini to the list --- Tool/Sources/Preferences/Types/ChatGPTModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 9670074c..c9d24b1b 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -4,6 +4,7 @@ public enum ChatGPTModel: String { case gpt35Turbo = "gpt-3.5-turbo" case gpt35Turbo16k = "gpt-3.5-turbo-16k" case gpt4o = "gpt-4o" + case gpt4oMini = "gpt-4o-mini" case gpt4 = "gpt-4" case gpt432k = "gpt-4-32k" case gpt4Turbo = "gpt-4-turbo" @@ -63,6 +64,8 @@ public extension ChatGPTModel { return 128000 case .gpt4o: return 128000 + case .gpt4oMini: + return 128000 } } From 2c3c58fee234a8ce5569475919895ee8a0c0ac83 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 19 Jul 2024 17:47:29 +0800 Subject: [PATCH 046/116] Simplify KeyBindingManager initialization --- Core/Package.swift | 4 +++- .../KeyBindingManager/KeyBindingManager.swift | 13 +++-------- .../TabToAcceptSuggestion.swift | 23 ++++++++----------- Core/Sources/Service/Service.swift | 11 +-------- 4 files changed, 16 insertions(+), 35 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index dff74b41..f18f6f37 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -329,12 +329,14 @@ let package = Package( .target( name: "KeyBindingManager", dependencies: [ + .product(name: "CommandHandler", package: "Tool"), .product(name: "Workspace", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Logger", package: "Tool"), - .product(name: "CGEventOverride", package: "CGEventOverride"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "CGEventOverride", package: "CGEventOverride"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), .testTarget( diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift index c3d6ed31..113d1450 100644 --- a/Core/Sources/KeyBindingManager/KeyBindingManager.swift +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -1,19 +1,12 @@ import Foundation import Workspace + public final class KeyBindingManager { let tabToAcceptSuggestion: TabToAcceptSuggestion - public init( - workspacePool: WorkspacePool, - acceptSuggestion: @escaping () -> Void, - dismissSuggestion: @escaping () -> Void - ) { - tabToAcceptSuggestion = .init( - workspacePool: workspacePool, - acceptSuggestion: acceptSuggestion, - dismissSuggestion: dismissSuggestion - ) + public init() { + tabToAcceptSuggestion = .init() } public func start() { diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index f1e154e5..d11095a3 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -1,6 +1,8 @@ import ActiveApplicationMonitor import AppKit import CGEventOverride +import CommandHandler +import Dependencies import Foundation import Logger import Preferences @@ -14,9 +16,9 @@ final class TabToAcceptSuggestion { Logger.service.debug("TabToAcceptSuggestion: \(message)") } - let workspacePool: WorkspacePool - let acceptSuggestion: () -> Void - let dismissSuggestion: () -> Void + @Dependency(\.workspacePool) var workspacePool + @Dependency(\.commandHandler) var commandHandler + private var CGEventObservationTask: Task? private var isObserving: Bool { CGEventObservationTask != nil } private let userDefaultsObserver = UserDefaultsObserver( @@ -43,16 +45,9 @@ final class TabToAcceptSuggestion { stopObservation() } - init( - workspacePool: WorkspacePool, - acceptSuggestion: @escaping () -> Void, - dismissSuggestion: @escaping () -> Void - ) { + init() { _ = ThreadSafeAccessToXcodeInspector.shared - self.workspacePool = workspacePool - self.acceptSuggestion = acceptSuggestion - self.dismissSuggestion = dismissSuggestion - + hook.add( .init( eventsOfInterest: [.keyDown], @@ -193,7 +188,7 @@ final class TabToAcceptSuggestion { ) if shouldAcceptSuggestion { - acceptSuggestion() + Task { await commandHandler.acceptSuggestion() } return .discarded } else { return .unchanged @@ -215,7 +210,7 @@ final class TabToAcceptSuggestion { filespace.presentingSuggestion != nil else { return .unchanged } - dismissSuggestion() + Task { await commandHandler.dismissSuggestion() } return .discarded default: return .unchanged diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index faff4f47..ee5be58b 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -57,16 +57,7 @@ public final class Service { let guiController = GraphicalUserInterfaceController() self.guiController = guiController globalShortcutManager = .init(guiController: guiController) - - keyBindingManager = .init( - workspacePool: workspacePool, - acceptSuggestion: { - Task { await PseudoCommandHandler().acceptSuggestion() } - }, - dismissSuggestion: { - Task { await PseudoCommandHandler().dismissSuggestion() } - } - ) + keyBindingManager = .init() #if canImport(ProService) proService = ProService() From 67a83ce284e3b6e95637b1a705c4e6ec95f81372 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 22 Jul 2024 15:12:31 +0800 Subject: [PATCH 047/116] Add extensions to String --- .../SuggestionProvider/String+Extension.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Tool/Sources/SuggestionProvider/String+Extension.swift b/Tool/Sources/SuggestionProvider/String+Extension.swift index d177e0b0..8842c775 100644 --- a/Tool/Sources/SuggestionProvider/String+Extension.swift +++ b/Tool/Sources/SuggestionProvider/String+Extension.swift @@ -8,4 +8,26 @@ public extension String { } return String(text) } + + func removedTrailingCharacters(in set: CharacterSet) -> String { + var text = self[...] + while let last = text.last, set.containsUnicodeScalars(of: last) { + text = text.dropLast(1) + } + return String(text) + } + + func removeLeadingCharacters(in set: CharacterSet) -> String { + var text = self[...] + while let first = text.first, set.containsUnicodeScalars(of: first) { + text = text.dropFirst() + } + return String(text) + } +} + +extension CharacterSet { + func containsUnicodeScalars(of character: Character) -> Bool { + return character.unicodeScalars.allSatisfy(contains(_:)) + } } From c5da3a14516cca202d4ce49228d1d4d80b0d7c56 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 2 Aug 2024 15:46:39 +0800 Subject: [PATCH 048/116] Update --- .../xcshareddata/xcschemes/ExtensionService.xcscheme | 3 --- 1 file changed, 3 deletions(-) diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme index eef52c11..b72db8dd 100644 --- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -46,9 +46,6 @@ reference = "container:TestPlan.xctestplan" default = "YES"> - - Date: Tue, 6 Aug 2024 19:15:55 +0800 Subject: [PATCH 049/116] Fix missing dependency --- Tool/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Package.swift b/Tool/Package.swift index 2a14d559..9c5604bd 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -384,6 +384,7 @@ let package = Package( "XcodeInspector", "BuiltinExtension", "ChatTab", + "SharedUIComponents", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), ] From 9ebf724a50fd3e40d39f401e7ed13b26d31ed022 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 6 Aug 2024 19:16:01 +0800 Subject: [PATCH 050/116] Add comment --- .../PostProcessingSuggestionServiceMiddleware.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index 6bdd4a38..c0de6dea 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -60,6 +60,7 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle } else { suggestion.text = "" } + suggestion.middlewareComments.append("Removed redundant closing parenthesis.") } } From 46d26ca1db011b0bf503d8090a0cd025125f3b4e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 6 Aug 2024 23:38:08 +0800 Subject: [PATCH 051/116] Fix accept suggestions, simplify suffix recovery --- .../SuggestionInjector.swift | 48 +---- .../AcceptSuggestionTests.swift | 199 +++++++++++++++--- 2 files changed, 185 insertions(+), 62 deletions(-) diff --git a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift index a7fcedaf..82c05ea1 100644 --- a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift @@ -34,7 +34,7 @@ public struct SuggestionInjector { } let firstRemovedLine = content[safe: start.line] - let lastRemovedLine = content[safe: end.line] + let lastRemovedLine = completion.replacingLines[safe: max(0, end.line - start.line)] let startLine = max(0, start.line) let endLine = max(start.line, min(end.line, content.endIndex - 1)) if startLine < content.endIndex { @@ -72,7 +72,7 @@ public struct SuggestionInjector { let recoveredSuffixLength = recoverSuffixIfNeeded( endOfReplacedContent: end, toBeInserted: &toBeInserted, - lastRemovedLine: lastRemovedLine, + originalLastRemovedLine: lastRemovedLine, lineEnding: lineEnding ) @@ -90,52 +90,26 @@ public struct SuggestionInjector { func recoverSuffixIfNeeded( endOfReplacedContent end: CursorPosition, toBeInserted: inout [String], - lastRemovedLine: String?, + originalLastRemovedLine: String?, lineEnding: String ) -> Int { // If there is no line removed, there is no need to recover anything. - guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 } + guard let lastRemovedLine = originalLastRemovedLine, + !lastRemovedLine.isEmptyOrNewLine else { return 0 } let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak() - // If the replaced range covers the whole line, return immediately. - guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.utf16.count - else { return 0 } - - // if we are not inserting anything, return immediately. - guard !toBeInserted.isEmpty, - let first = toBeInserted.first?.droppedLineBreak(), !first.isEmpty, - let last = toBeInserted.last?.droppedLineBreak(), !last.isEmpty - else { return 0 } - - // case 1: user keeps typing as the suggestion suggests. - - if first.hasPrefix(lastRemovedLineCleaned) { - return 0 - } - - // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) - // locate the split index, the prefix of which matches the suggestion prefix. - var splitIndex: String.Index? - - for offset in end.character..` diff --git a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index 1f93b021..bdd96db9 100644 --- a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -21,7 +21,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 0) - ) + ), + replacingLines: "".breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() var lines = content.breakIntoEditorStyleLines() @@ -67,7 +68,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 12) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -110,7 +112,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 12) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -153,7 +156,12 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 12) - ) + ), + replacingLines: """ + struct Cat { + var name + } + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -182,18 +190,21 @@ final class AcceptSuggestionTests: XCTestCase { func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws { let content = """ print("") - """ + """ // typed ") let text = """ print("Hello World!") """ let suggestion = CodeSuggestion( id: "", text: text, - position: .init(line: 0, character: 6), + position: .init(line: 0, character: 7), range: .init( start: .init(line: 0, character: 0), - end: .init(line: 0, character: 6) - ) + end: .init(line: 0, character: 7) + ), + replacingLines: """ + print(" + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -226,11 +237,14 @@ final class AcceptSuggestionTests: XCTestCase { let suggestion = CodeSuggestion( id: "", text: text, - position: .init(line: 0, character: 6), + position: .init(line: 0, character: 7), range: .init( start: .init(line: 0, character: 0), - end: .init(line: 0, character: 6) - ) + end: .init(line: 0, character: 7) + ), + replacingLines: """ + print("") + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -249,7 +263,7 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertEqual(cursor, .init(line: 0, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") - + """) } @@ -271,7 +285,10 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 6) - ) + ), + replacingLines: """ + struct + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -315,7 +332,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 20) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -361,7 +379,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 0) - ) + ), + replacingLines: "".breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -410,7 +429,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 2, character: 1) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -463,7 +483,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 4, character: 7), end: .init(line: 5, character: 34) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -510,7 +531,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 12) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var lines = content.breakIntoEditorStyleLines() @@ -543,7 +565,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 12) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var lines = content.breakIntoEditorStyleLines() @@ -580,7 +603,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 13) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -623,7 +647,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 13) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -666,7 +691,12 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 1, character: 0), end: .init(line: 1, character: 13) - ) + ), + replacingLines: """ + struct 😹😹 { + var name: + } + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -712,7 +742,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 2, character: 1) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -754,7 +785,10 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 6) - ) + ), + replacingLines: """ + print(") + """.breakLines(appendLineBreakToLastLine: true) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -790,7 +824,8 @@ final class AcceptSuggestionTests: XCTestCase { range: .init( start: .init(line: 0, character: 0), end: .init(line: 0, character: 11) - ) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) ) var lines = content.breakIntoEditorStyleLines() @@ -809,6 +844,120 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_accept_suggestion_in_the_middle_single_line() async throws { + let content = """ + let foobar = 1 + """ + let text = """ + let fooBar + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 10) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 10)) + XCTAssertEqual(lines.joined(separator: ""), """ + let fooBar = 1 + + """) + } + + func test_accept_suggestion_in_the_middle_single_line_case_2() async throws { + let content = """ + let pikachecker = 1 + """ + let text = """ + let pikaChecker + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 16), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 23) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 16) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 23)) + XCTAssertEqual(lines.joined(separator: ""), """ + let pikaChecker = 1 + + """) + } + + func test_accept_suggestion_rewriting_the_single_line() async throws { + let content = """ + let foobar = + """ + let text = """ + let zooKoo = 2 + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 14)) + XCTAssertEqual(lines.joined(separator: ""), """ + let zooKoo = 2 + + """) + } } extension String { From c875a4ed4d6db5dedcb10ed02876c9af32809170 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 7 Aug 2024 02:23:11 +0800 Subject: [PATCH 052/116] Update structure of middlewares --- .../SuggestionServiceMiddleware.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index 9d7f6af6..f26492c1 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -17,19 +17,29 @@ public enum SuggestionServiceMiddlewareContainer { DisabledLanguageSuggestionServiceMiddleware(), PostProcessingSuggestionServiceMiddleware() ] + + static var leadingMiddlewares: [SuggestionServiceMiddleware] = [] - static var customMiddlewares: [SuggestionServiceMiddleware] = [] + static var trailingMiddlewares: [SuggestionServiceMiddleware] = [] public static var middlewares: [SuggestionServiceMiddleware] { - builtInMiddlewares + customMiddlewares + leadingMiddlewares + builtInMiddlewares + trailingMiddlewares } public static func addMiddleware(_ middleware: SuggestionServiceMiddleware) { - customMiddlewares.append(middleware) + trailingMiddlewares.append(middleware) } public static func addMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) { - customMiddlewares.append(contentsOf: middlewares) + trailingMiddlewares.append(contentsOf: middlewares) + } + + public static func addLeadingMiddleware(_ middleware: SuggestionServiceMiddleware) { + leadingMiddlewares.append(middleware) + } + + public static func addLeadingMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) { + leadingMiddlewares.append(contentsOf: middlewares) } } From 0be87b369735c51826f985f44e4e014cb0ff6e5c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 7 Aug 2024 02:23:24 +0800 Subject: [PATCH 053/116] Move code to post processing middleware --- .../SuggestionService/SuggestionService.swift | 11 +---------- .../PostProcessingSuggestionServiceMiddleware.swift | 12 ++++++++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 35414692..335f0c83 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -84,16 +84,7 @@ public extension SuggestionService { } } - var result = try await getSuggestion(request, workspaceInfo) - if !request.lines.isEmpty { - for index in result.indices { - let range = result[index].range - let lowerBound = max(0, range.start.line) - let upperBound = max(lowerBound, min(request.lines.count - 1, range.end.line)) - result[index].replacingLines = Array(request.lines[lowerBound...upperBound]) - } - } - return result + return try await getSuggestion(request, workspaceInfo) } catch let error as SuggestionServiceError { throw error } catch { diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift index c0de6dea..227aac22 100644 --- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -17,6 +17,7 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle Self.removeTrailingWhitespacesAndNewlines(&suggestion) Self.removeRedundantClosingParenthesis(&suggestion, lines: request.lines) if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + Self.injectReplacingLines(&suggestion, request: request) return suggestion } } @@ -24,6 +25,17 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { suggestion.text = suggestion.text.removedTrailingWhitespacesAndNewlines() } + + static func injectReplacingLines( + _ suggestion: inout CodeSuggestion, + request: SuggestionRequest + ) { + guard !request.lines.isEmpty else { return } + let range = suggestion.range + let lowerBound = max(0, range.start.line) + let upperBound = max(lowerBound, min(request.lines.count - 1, range.end.line)) + suggestion.replacingLines = Array(request.lines[lowerBound...upperBound]) + } /// Remove the parenthesis in the last line of the suggestion if /// - It contains only closing parenthesis From de877612441edcf00adf88f0d52da8c09713a033 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 12 Aug 2024 18:08:08 +0800 Subject: [PATCH 054/116] Tweak chat panel window behavior --- .../SuggestionWidget/ChatPanelWindow.swift | 2 +- .../WidgetWindowsController.swift | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 7c3a2d3e..dae12f6e 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -47,7 +47,7 @@ final class ChatPanelWindow: WidgetWindow { ) { self.minimizeWindow = minimizeWindow super.init( - contentRect: .zero, + contentRect: .init(x: 0, y: 0, width: 300, height: 400), styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 36039bfc..b0e9d0ca 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -418,7 +418,12 @@ extension WidgetWindowsController { windows.widgetWindow.alphaValue = if noFocus { 0 } else if previousAppIsXcode { - 1 + if windows.chatPanelWindow.isFullscreen, + windows.chatPanelWindow.isOnActiveSpace { + 0 + } else { + 1 + } } else { 0 } @@ -573,11 +578,11 @@ extension WidgetWindowsController { @MainActor func handleXcodeFullscreenChange() async { - guard let activeXcode = await XcodeInspector.shared.safe.activeXcode - else { return } + let activeXcode = await XcodeInspector.shared.safe.activeXcode - let xcode = activeXcode.appElement - let isFullscreen = if let xcodeWindow = xcode.focusedWindow { + let isFullscreen = if let xcode = activeXcode?.appElement, + let xcodeWindow = xcode.focusedWindow + { xcodeWindow.isFullScreen && xcode.isFrontmost } else { false @@ -593,10 +598,8 @@ extension WidgetWindowsController { $0.send(.didChangeActiveSpace(fullscreen: isFullscreen)) } - if windows.fullscreenDetector.isOnActiveSpace { - if xcode.focusedWindow != nil { - windows.orderFront() - } + if windows.fullscreenDetector.isOnActiveSpace, isFullscreen { + windows.orderFront() } } } @@ -855,6 +858,10 @@ class WidgetWindow: CanBecomeKeyWindow { [.fullScreenAuxiliary, .transient] } + var isFullscreen: Bool { + styleMask.contains(.fullScreen) + } + private var state: State? { didSet { guard state != oldValue else { return } @@ -884,3 +891,4 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { minimumWidgetLevel = NSWindow.Level.floating.rawValue return .init(minimumWidgetLevel + addition) } + From 270b46e2b1d7f58f7a658f08c4145123e9712e04 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 19 Aug 2024 22:27:57 +0800 Subject: [PATCH 055/116] Support installing launch agent to ~/Library/LaunchAgents when the app is not in /Applications --- Core/Sources/HostApp/LaunchAgentManager.swift | 5 +- .../LaunchAgentManager.swift | 118 +++++++++--------- 2 files changed, 64 insertions(+), 59 deletions(-) diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift index ee031cb5..44937bb1 100644 --- a/Core/Sources/HostApp/LaunchAgentManager.swift +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -7,11 +7,10 @@ extension LaunchAgentManager { serviceIdentifier: Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + ".CommunicationBridge", - executablePath: Bundle.main.bundleURL + executableURL: Bundle.main.bundleURL .appendingPathComponent("Contents") .appendingPathComponent("Applications") - .appendingPathComponent("CommunicationBridge") - .path, + .appendingPathComponent("CommunicationBridge"), bundleIdentifier: Bundle.main .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String ) diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index e0c8f814..3caf179b 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -4,7 +4,7 @@ import ServiceManagement public struct LaunchAgentManager { let lastLaunchAgentVersionKey = "LastLaunchAgentVersion" let serviceIdentifier: String - let executablePath: String + let executableURL: URL let bundleIdentifier: String var launchAgentDirURL: URL { @@ -16,15 +16,14 @@ public struct LaunchAgentManager { launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path } - public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) { + public init(serviceIdentifier: String, executableURL: URL, bundleIdentifier: String) { self.serviceIdentifier = serviceIdentifier - self.executablePath = executablePath + self.executableURL = executableURL self.bundleIdentifier = bundleIdentifier } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { if #available(macOS 13, *) { - await removeObsoleteLaunchAgent() try await setupLaunchAgent() } else { if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 { @@ -33,48 +32,18 @@ public struct LaunchAgentManager { } guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } try await setupLaunchAgent() - await removeObsoleteLaunchAgent() } } public func setupLaunchAgent() async throws { if #available(macOS 13, *) { - let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try bridgeLaunchAgent.register() - } else { - let content = """ - - - - - Label - \(serviceIdentifier) - Program - \(executablePath) - MachServices - - \(serviceIdentifier) - - - AssociatedBundleIdentifiers - - \(bundleIdentifier) - \(serviceIdentifier) - - - - """ - if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { - try FileManager.default.createDirectory( - at: launchAgentDirURL, - withIntermediateDirectories: false - ) + if executableURL.path.hasPrefix("/Applications") { + try setupLaunchAgentWithPredefinedPlist() + } else { + try await setupLaunchAgentWithDynamicPlist() } - FileManager.default.createFile( - atPath: launchAgentPath, - contents: content.data(using: .utf8) - ) - try await launchctl("load", launchAgentPath) + } else { + try await setupLaunchAgentWithDynamicPlist() } let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) @@ -85,7 +54,11 @@ public struct LaunchAgentManager { public func removeLaunchAgent() async throws { if #available(macOS 13, *) { let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") - try await bridgeLaunchAgent.unregister() + try? await bridgeLaunchAgent.unregister() + if FileManager.default.fileExists(atPath: launchAgentPath) { + try? await launchctl("unload", launchAgentPath) + try? FileManager.default.removeItem(atPath: launchAgentPath) + } } else { try await launchctl("unload", launchAgentPath) try FileManager.default.removeItem(atPath: launchAgentPath) @@ -97,23 +70,56 @@ public struct LaunchAgentManager { try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) } } +} - public func removeObsoleteLaunchAgent() async { - if #available(macOS 13, *) { - let path = launchAgentPath - if FileManager.default.fileExists(atPath: path) { - try? await launchctl("unload", path) - try? FileManager.default.removeItem(atPath: path) - } - } else { - let path = launchAgentPath.replacingOccurrences( - of: "ExtensionService", - with: "XPCService" +extension LaunchAgentManager { + @available(macOS 13, *) + func setupLaunchAgentWithPredefinedPlist() throws { + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try bridgeLaunchAgent.register() + } + + func setupLaunchAgentWithDynamicPlist() async throws { + if FileManager.default.fileExists(atPath: launchAgentPath) { + throw E(errorDescription: "Launch agent already exists.") + } + + let content = """ + + + + + Label + \(serviceIdentifier) + Program + \(executableURL.path) + MachServices + + \(serviceIdentifier) + + + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + + + + """ + if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { + try FileManager.default.createDirectory( + at: launchAgentDirURL, + withIntermediateDirectories: false ) - if FileManager.default.fileExists(atPath: path) { - try? FileManager.default.removeItem(atPath: path) - } } + FileManager.default.createFile( + atPath: launchAgentPath, + contents: content.data(using: .utf8) + ) + #if DEBUG + #else + try await launchctl("load", launchAgentPath) + #endif } } @@ -170,7 +176,7 @@ private func launchctl(_ args: String...) async throws { return try await process("/bin/launchctl", args) } -struct E: Error, LocalizedError { +private struct E: Error, LocalizedError { var errorDescription: String? } From 008f565b6bd0ebd984ef919e1b30599cde3f5739 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 19 Aug 2024 22:28:11 +0800 Subject: [PATCH 056/116] Update UI to present alerts --- Core/Sources/HostApp/General.swift | 205 +++++++++++++++++++++++-- Core/Sources/HostApp/GeneralView.swift | 171 ++++++++------------- Core/Sources/HostApp/HostApp.swift | 2 +- 3 files changed, 260 insertions(+), 118 deletions(-) diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 0dca4505..96ade16c 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -12,49 +12,196 @@ struct General { var xpcServiceVersion: String? var isAccessibilityPermissionGranted: Bool? var isReloading = false + @Presents var alert: AlertState? } - enum Action: Equatable { + enum Action { case appear case setupLaunchAgentIfNeeded + case setupLaunchAgentClicked + case removeLaunchAgentClicked + case reloadLaunchAgentClicked case openExtensionManager case reloadStatus case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) case failedReloading + case alert(PresentationAction) + + case setupLaunchAgent + case finishSetupLaunchAgent + case finishRemoveLaunchAgent + case finishReloadLaunchAgent + + @CasePathable + enum Alert: Equatable { + case moveToApplications + case moveTo(URL) + case install + } } @Dependency(\.toast) var toast - + struct ReloadStatusCancellableId: Hashable {} + static var didWarnInstallationPosition: Bool { + get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") } + set { UserDefaults.standard.set(newValue, forKey: "didWarnInstallationPosition") } + } + + static var bundleIsInApplicationsFolder: Bool { + Bundle.main.bundleURL.path.hasPrefix("/Applications") + } + var body: some ReducerOf { Reduce { state, action in switch action { case .appear: - return .run { send in - if UserDefaults.shared.value(for: \.doNotInstallLaunchAgentAutomatically) { - return + if Self.bundleIsInApplicationsFolder { + return .run { send in + await send(.setupLaunchAgentIfNeeded) } - await send(.setupLaunchAgentIfNeeded) } + if !Self.didWarnInstallationPosition { + Self.didWarnInstallationPosition = true + state.alert = .init { + TextState("Move to Applications Folder?") + } actions: { + ButtonState(action: .moveToApplications) { + TextState("Move") + } + ButtonState(role: .cancel) { + TextState("Not Now") + } + } message: { + TextState( + "To ensure the best experience, please move the app to the Applications folder. If the app is not inside the Applications folder, please set up the launch agent manually by clicking the button." + ) + } + } + + return .none + case .setupLaunchAgentIfNeeded: return .run { send in #if DEBUG // do not auto install on debug build #else - Task { - do { - try await LaunchAgentManager() - .setupLaunchAgentForTheFirstTimeIfNeeded() - } catch { - toast(error.localizedDescription, .error) - } + do { + try await LaunchAgentManager() + .setupLaunchAgentForTheFirstTimeIfNeeded() + } catch { + toast(error.localizedDescription, .error) } #endif await send(.reloadStatus) } + case .setupLaunchAgentClicked: + if Self.bundleIsInApplicationsFolder { + return .run { send in + await send(.setupLaunchAgent) + } + } + + state.alert = .init { + TextState("Setup Launch Agent") + } actions: { + ButtonState(action: .install) { + TextState("Setup") + } + + ButtonState(action: .moveToApplications) { + TextState("Move to Applications Folder") + } + + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState( + "It's recommended to move the app into the Applications folder. But you can still keep it in the current folder and install the launch agent to ~/Library/LaunchAgents." + ) + } + + return .none + + case .removeLaunchAgentClicked: + return .run { send in + do { + try await LaunchAgentManager().removeLaunchAgent() + await send(.finishRemoveLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .reloadLaunchAgentClicked: + return .run { send in + do { + try await LaunchAgentManager().reloadLaunchAgent() + await send(.finishReloadLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .setupLaunchAgent: + return .run { send in + do { + try await LaunchAgentManager().setupLaunchAgent() + await send(.finishSetupLaunchAgent) + } catch { + toast(error.localizedDescription, .error) + } + await send(.reloadStatus) + } + + case .finishSetupLaunchAgent: + state.alert = .init { + TextState("Launch Agent Installed") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been installed. Please restart the app." + ) + } + return .none + + case .finishRemoveLaunchAgent: + state.alert = .init { + TextState("Launch Agent Removed") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been removed." + ) + } + return .none + + case .finishReloadLaunchAgent: + state.alert = .init { + TextState("Launch Agent Reloaded") + } actions: { + ButtonState { + TextState("OK") + } + } message: { + TextState( + "The launch agent has been reloaded." + ) + } + return .none + case .openExtensionManager: return .run { send in let service = try getService() @@ -107,6 +254,38 @@ struct General { case .failedReloading: state.isReloading = false return .none + + case let .alert(.presented(action)): + switch action { + case .moveToApplications: + return .run { send in + let appURL = URL(fileURLWithPath: "/Applications") + await send(.alert(.presented(.moveTo(appURL)))) + } + + case let .moveTo(url): + return .run { _ in + do { + try FileManager.default.moveItem( + at: Bundle.main.bundleURL, + to: url.appendingPathComponent( + Bundle.main.bundleURL.lastPathComponent + ) + ) + await NSApplication.shared.terminate(nil) + } catch { + toast(error.localizedDescription, .error) + } + } + case .install: + return .run { send in + await send(.setupLaunchAgent) + } + } + + case .alert(.dismiss): + state.alert = nil + return .none } } } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index ba57a242..6b08c69b 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -16,7 +16,7 @@ struct GeneralView: View { SettingsDivider() ExtensionServiceView(store: store) SettingsDivider() - LaunchAgentView() + LaunchAgentView(store: store) SettingsDivider() GeneralSettingsView() } @@ -30,64 +30,68 @@ struct GeneralView: View { struct AppInfoView: View { @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @Environment(\.updateChecker) var updateChecker - let store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { - VStack(alignment: .leading) { - HStack(alignment: .top) { - Text( - Bundle.main - .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String - ?? "Copilot for Xcode" - ) - .font(.title) - Text(appVersion ?? "") - .font(.footnote) - .foregroundColor(.secondary) - - Spacer() + WithPerceptionTracking { + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text( + Bundle.main + .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "Copilot for Xcode" + ) + .font(.title) + Text(appVersion ?? "") + .font(.footnote) + .foregroundColor(.secondary) - Button(action: { - store.send(.openExtensionManager) - }) { - HStack(spacing: 2) { - Image(systemName: "puzzlepiece.extension.fill") - Text("Extensions") + Spacer() + + Button(action: { + store.send(.openExtensionManager) + }) { + HStack(spacing: 2) { + Image(systemName: "puzzlepiece.extension.fill") + Text("Extensions") + } } - } - Button(action: { - updateChecker.checkForUpdates() - }) { - HStack(spacing: 2) { - Image(systemName: "arrow.up.right.circle.fill") - Text("Check for Updates") + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Image(systemName: "arrow.up.right.circle.fill") + Text("Check for Updates") + } } } - } - HStack(spacing: 16) { - Link( - destination: URL(string: "https://github.com/intitni/CopilotForXcode")! - ) { - HStack(spacing: 2) { - Image(systemName: "link") - Text("GitHub") + HStack(spacing: 16) { + Link( + destination: URL(string: "https://github.com/intitni/CopilotForXcode")! + ) { + HStack(spacing: 2) { + Image(systemName: "link") + Text("GitHub") + } } - } - .focusable(false) - .foregroundColor(.accentColor) + .focusable(false) + .foregroundColor(.accentColor) - Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) { - HStack(spacing: 2) { - Image(systemName: "cup.and.saucer.fill") - Text("Buy Me A Coffee") + Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) { + HStack(spacing: 2) { + Image(systemName: "cup.and.saucer.fill") + Text("Buy Me A Coffee") + } } + .foregroundColor(.accentColor) + .focusable(false) } - .foregroundColor(.accentColor) - .focusable(false) } - }.padding() + .padding() + .alert($store.scope(state: \.alert, action: \.alert)) + } } } @@ -149,75 +153,34 @@ struct ExtensionServiceView: View { } struct LaunchAgentView: View { + @Perception.Bindable var store: StoreOf @Environment(\.toast) var toast - @State var isDidRemoveLaunchAgentAlertPresented = false - @State var isDidSetupLaunchAgentAlertPresented = false - @State var isDidRestartLaunchAgentAlertPresented = false var body: some View { - VStack(alignment: .leading) { - HStack { - Button(action: { - Task { - do { - try await LaunchAgentManager().setupLaunchAgent() - isDidSetupLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } + WithPerceptionTracking { + VStack(alignment: .leading) { + HStack { + Button(action: { + store.send(.setupLaunchAgentClicked) + }) { + Text("Setup Launch Agent") } - }) { - Text("Set Up Launch Agent") - } - .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) { - .init( - title: Text("Finished Launch Agent Setup"), - message: Text( - "Please refresh the Copilot status. (The first refresh may fail)" - ), - dismissButton: .default(Text("OK")) - ) - } - Button(action: { - Task { - do { - try await LaunchAgentManager().removeLaunchAgent() - isDidRemoveLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } + Button(action: { + store.send(.removeLaunchAgentClicked) + }) { + Text("Remove Launch Agent") } - }) { - Text("Remove Launch Agent") - } - .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Removed"), - dismissButton: .default(Text("OK")) - ) - } - Button(action: { - Task { - do { - try await LaunchAgentManager().reloadLaunchAgent() - isDidRestartLaunchAgentAlertPresented = true - } catch { - toast(error.localizedDescription, .error) - } + Button(action: { + store.send(.reloadLaunchAgentClicked) + }) { + Text("Reload Launch Agent") } - }) { - Text("Reload Launch Agent") - }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) { - .init( - title: Text("Launch Agent Reloaded"), - dismissButton: .default(Text("OK")) - ) } } + .padding() } - .padding() } } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 69ec3120..575852a6 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -20,7 +20,7 @@ struct HostApp { var embeddingModelManagement = EmbeddingModelManagement.State() } - enum Action: Equatable { + enum Action { case appear case informExtensionServiceAboutLicenseKeyChange case general(General.Action) From 43cfbe418da668257c367868e2ff86243d45b86b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 19 Aug 2024 23:58:52 +0800 Subject: [PATCH 057/116] Simplify UI injection --- ...gestionSettingsCheatsheetSectionView.swift | 71 ------------------- .../Suggestion/SuggestionSettingsView.swift | 20 +++--- Core/Sources/HostApp/HostApp.swift | 21 ++---- Core/Sources/HostApp/TabContainer.swift | 28 ++++---- .../SharedUIComponents}/SubSection.swift | 22 +++--- .../SharedUIComponents/TabContainer.swift | 70 ++++++++++++++++++ 6 files changed, 111 insertions(+), 121 deletions(-) delete mode 100644 Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift rename {Core/Sources/HostApp/SharedComponents => Tool/Sources/SharedUIComponents}/SubSection.swift (82%) create mode 100644 Tool/Sources/SharedUIComponents/TabContainer.swift diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift deleted file mode 100644 index d0da53a1..00000000 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Client -import Preferences -import SharedUIComponents -import SwiftUI -import XPCShared - -#if canImport(ProHostApp) -import ProHostApp -#endif - -struct SuggestionSettingsCheatsheetSectionView: View { - final class Settings: ObservableObject { - @AppStorage(\.isSuggestionSenseEnabled) - var isSuggestionSenseEnabled - @AppStorage(\.isSuggestionTypeInTheMiddleEnabled) - var isSuggestionTypeInTheMiddleEnabled - } - - @StateObject var settings = Settings() - - var body: some View { - #if canImport(ProHostApp) - SubSection( - title: Text("Suggestion Sense (Experimental)"), - description: Text(""" - This cheatsheet will try to improve the suggestion by inserting relevant symbol \ - interfaces in the editing scope to the prompt. - - Some suggestion services may have their own RAG system with a higher priority. - """) - ) { - Form { - WithFeatureEnabled(\.suggestionSense) { - Toggle(isOn: $settings.isSuggestionSenseEnabled) { - Text("Enable suggestion sense") - } - } - } - } - - SubSection( - title: Text("Type-in-the-Middle Hack"), - description: Text(""" - Suggestion service don't always handle the case where the text cursor is in the middle \ - of a line. This cheatsheet will try to trick the suggestion service to also generate \ - suggestions in these cases. - - It can be useful in the following cases: - - Fixing a typo in the middle of a line. - - Getting suggestions from a line with Xcode placeholders. - - and more... - """) - ) { - Form { - Toggle(isOn: $settings.isSuggestionTypeInTheMiddleEnabled) { - Text("Enable type-in-the-middle hack") - } - } - } - - #else - Text("Not Available") - #endif - } -} - -#Preview { - SuggestionSettingsCheatsheetSectionView() - .padding() -} - diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift index 7cc461bb..632769a4 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift @@ -4,14 +4,14 @@ import SharedUIComponents import SwiftUI import XPCShared -#if canImport(ProHostApp) -import ProHostApp -#endif - struct SuggestionSettingsView: View { - enum Tab { + var tabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "SuggestionSettings") + } + + enum Tab: Hashable { case general - case suggestionCheatsheet + case other(String) } @State var tabSelection: Tab = .general @@ -20,7 +20,9 @@ struct SuggestionSettingsView: View { VStack(spacing: 0) { Picker("", selection: $tabSelection) { Text("General").tag(Tab.general) - Text("Cheatsheet").tag(Tab.suggestionCheatsheet) + ForEach(tabContainer.tabs, id: \.id) { tab in + Text(tab.title).tag(Tab.other(tab.id)) + } } .pickerStyle(.segmented) .padding(8) @@ -33,8 +35,8 @@ struct SuggestionSettingsView: View { switch tabSelection { case .general: SuggestionSettingsGeneralSectionView() - case .suggestionCheatsheet: - SuggestionSettingsCheatsheetSectionView() + case let .other(id): + tabContainer.tabView(for: id) } }.padding() } diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift index 575852a6..198e48c2 100644 --- a/Core/Sources/HostApp/HostApp.swift +++ b/Core/Sources/HostApp/HostApp.swift @@ -4,7 +4,7 @@ import Foundation import KeyboardShortcuts #if canImport(LicenseManagement) -import LicenseManagement +import ProHostApp #endif extension KeyboardShortcuts.Name { @@ -22,7 +22,6 @@ struct HostApp { enum Action { case appear - case informExtensionServiceAboutLicenseKeyChange case general(General.Action) case chatModelManagement(ChatModelManagement.Action) case embeddingModelManagement(EmbeddingModelManagement.Action) @@ -50,22 +49,10 @@ struct HostApp { Reduce { _, action in switch action { case .appear: - return .none - - case .informExtensionServiceAboutLicenseKeyChange: - #if canImport(LicenseManagement) - return .run { _ in - let service = try getService() - do { - try await service - .postNotification(name: Notification.Name.licenseKeyChanged.rawValue) - } catch { - toast(error.localizedDescription, .error) - } - } - #else - return .none + #if canImport(ProHostApp) + ProHostApp.start() #endif + return .none case .general: return .none diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 30ae0187..8616b5af 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -2,14 +2,11 @@ import ComposableArchitecture import Dependencies import Foundation import LaunchAgentManager +import SharedUIComponents import SwiftUI import Toast import UpdateChecker -#if canImport(ProHostApp) -import ProHostApp -#endif - @MainActor let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) @@ -19,6 +16,10 @@ public struct TabContainer: View { @State private var tabBarItems = [TabBarItem]() @State var tag: Int = 0 + var externalTabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "TabContainer") + } + public init() { toastController = ToastControllerDependencyKey.liveValue store = hostAppStore @@ -59,15 +60,16 @@ public struct TabContainer: View { title: "Custom Command", image: "command.square" ) - #if canImport(ProHostApp) - PlusView(onLicenseKeyChanged: { - store.send(.informExtensionServiceAboutLicenseKeyChange) - }).tabBarItem( - tag: 5, - title: "Plus", - image: "plus.diamond" - ) - #endif + + ForEach(0..: View { - let title: Title - let description: Description - @ViewBuilder let content: () -> Content +public struct SubSection: View { + public let title: Title + public let description: Description + @ViewBuilder public let content: () -> Content - init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { + public init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { self.title = title self.description = description self.content = content } - var body: some View { + public var body: some View { VStack(alignment: .leading) { if !(title is EmptyView && description is EmptyView) { VStack(alignment: .leading, spacing: 8) { @@ -43,31 +43,31 @@ struct SubSection: View { } } -extension SubSection where Description == Text { +public extension SubSection where Description == Text { init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) { self.init(title: title, description: Text(description), content: content) } } -extension SubSection where Description == EmptyView { +public extension SubSection where Description == EmptyView { init(title: Title, @ViewBuilder content: @escaping () -> Content) { self.init(title: title, description: EmptyView(), content: content) } } -extension SubSection where Title == EmptyView { +public extension SubSection where Title == EmptyView { init(description: Description, @ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: description, content: content) } } -extension SubSection where Title == EmptyView, Description == EmptyView { +public extension SubSection where Title == EmptyView, Description == EmptyView { init(@ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: EmptyView(), content: content) } } -extension SubSection where Title == EmptyView, Description == Text { +public extension SubSection where Title == EmptyView, Description == Text { init(description: String, @ViewBuilder content: @escaping () -> Content) { self.init(title: EmptyView(), description: description, content: content) } diff --git a/Tool/Sources/SharedUIComponents/TabContainer.swift b/Tool/Sources/SharedUIComponents/TabContainer.swift new file mode 100644 index 00000000..06611861 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/TabContainer.swift @@ -0,0 +1,70 @@ +import Dependencies +import Foundation +import SwiftUI + +public final class ExternalTabContainer { + public static var tabContainers = [String: ExternalTabContainer]() + + public struct TabItem: Identifiable { + public var id: String + public var title: String + public var image: String + public let viewBuilder: () -> AnyView + + public init( + id: String, + title: String, + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + self.id = id + self.title = title + self.image = image + self.viewBuilder = { AnyView(viewBuilder()) } + } + } + + public var tabs: [TabItem] = [] + public init() { tabs = [] } + + public static func tabContainer(for id: String) -> ExternalTabContainer { + if let tabContainer = tabContainers[id] { + return tabContainer + } + let tabContainer = ExternalTabContainer() + tabContainers[id] = tabContainer + return tabContainer + } + + @ViewBuilder + public func tabView(for id: String) -> some View { + if let tab = tabs.first(where: { $0.id == id }) { + tab.viewBuilder() + } + } + + public func registerTab( + id: String, + title: String, + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + tabs.append(TabItem(id: id, title: title, image: image, viewBuilder: viewBuilder)) + } + + public static func registerTab( + for tabContainerId: String, + id: String, + title: String, + image: String = "", + @ViewBuilder viewBuilder: @escaping () -> V + ) { + tabContainer(for: tabContainerId).registerTab( + id: id, + title: title, + image: image, + viewBuilder: viewBuilder + ) + } +} + From 4b1224fc2ce8defd1e07947f790457d2b120475f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 9 Aug 2024 16:13:26 +0800 Subject: [PATCH 058/116] Add ChatAgent --- Tool/Sources/ChatBasic/ChatAgent.swift | 33 +++++++++++++++++ Tool/Sources/ChatBasic/ChatMessage.swift | 45 +++++++++++++++--------- 2 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 Tool/Sources/ChatBasic/ChatAgent.swift diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift new file mode 100644 index 00000000..9f74517c --- /dev/null +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum ChatAgentResponse { + /// Post the status of the current message. + case status(String) + /// Send a token of the text message to the current message. + case contentToken(String) + /// Update the attachments of the current message. + case attachments([URL]) + /// Update the references of the current message. + case references([ChatMessage.Reference]) + /// End the message. The next contents will be sent as a new message. + case finishMessage +} + +public struct ChatAgentRequest { + public var text: String + public var history: [ChatMessage] + public var extraContext: String + + public init(text: String, history: [ChatMessage], extraContext: String) { + self.text = text + self.history = history + self.extraContext = extraContext + } +} + +public protocol ChatAgent { + typealias Response = ChatAgentResponse + typealias Request = ChatAgentRequest + func send(_ request: Request) async -> AsyncThrowingStream +} + diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 689fc0e5..d540b4ea 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -47,21 +47,24 @@ public struct ChatMessage: Equatable, Codable { } public struct Reference: Codable, Equatable { - public enum Kind: String, Codable { - case `class` - case `struct` - case `enum` - case `actor` - case `protocol` - case `extension` - case `case` - case property - case `typealias` - case function - case method + public enum Kind: Codable, Equatable { + public enum Symbol: String, Codable { + case `class` + case `struct` + case `enum` + case `actor` + case `protocol` + case `extension` + case `case` + case property + case `typealias` + case function + case method + } + case symbol(Symbol) case text case webpage - case other + case other(String) } public var title: String @@ -78,8 +81,8 @@ public struct ChatMessage: Equatable, Codable { subTitle: String, content: String, uri: String, - startLine: Int?, - endLine: Int?, + startLine: Int? = nil, + endLine: Int? = nil, kind: Kind ) { self.title = title @@ -116,6 +119,12 @@ public struct ChatMessage: Equatable, Codable { /// The id of the message. public var id: ID + + /// The id of the sender of the message. + public var senderId: String? + + /// The id of the message that this message is a response to. + public var responseTo: ID? /// The number of tokens of this message. public var tokensCount: Int? @@ -134,6 +143,8 @@ public struct ChatMessage: Equatable, Codable { public init( id: String = UUID().uuidString, + senderId: String? = nil, + repinesToo: String? = nil, role: Role, content: String?, name: String? = nil, @@ -143,6 +154,8 @@ public struct ChatMessage: Equatable, Codable { references: [Reference] = [] ) { self.role = role + self.senderId = senderId + self.responseTo = responseTo self.content = content self.name = name self.toolCalls = toolCalls @@ -154,7 +167,7 @@ public struct ChatMessage: Equatable, Codable { } public struct ReferenceKindFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Reference.Kind { .other } + public static var defaultValue: ChatMessage.Reference.Kind { .other("Unknown") } } public struct ChatMessageRoleFallback: FallbackValueProvider { From ba7a741803613c15d2a0024201d68b60ac4c51ba Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 11 Aug 2024 00:59:10 +0800 Subject: [PATCH 059/116] Add RAGChatAgent --- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 2 +- .../ChatGPTChatTab/Views/BotMessage.swift | 106 +++++++++--------- Tool/Package.swift | 10 ++ Tool/Sources/ChatBasic/ChatMessage.swift | 2 +- Tool/Sources/RAGChatAgent/RAGChatAgent.swift | 74 ++++++++++++ .../RAGChatAgent/RAGChatAgentCapability.swift | 59 ++++++++++ 6 files changed, 201 insertions(+), 52 deletions(-) create mode 100644 Tool/Sources/RAGChatAgent/RAGChatAgent.swift create mode 100644 Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 139ed7ab..ac036cc6 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -504,7 +504,7 @@ struct ChatPanel_Preview: PreviewProvider { subtitle: "Hi Hi Hi Hi", uri: "https://google.com", startLine: nil, - kind: .class + kind: .symbol(.class) ), ] ), diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index 2605d6d5..ea281dc6 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -139,28 +139,31 @@ struct ReferenceIcon: View { RoundedRectangle(cornerRadius: 4) .fill({ switch kind { - case .class: - Color.purple - case .struct: - Color.purple - case .enum: - Color.purple - case .actor: - Color.purple - case .protocol: - Color.purple - case .extension: - Color.indigo - case .case: - Color.green - case .property: - Color.teal - case .typealias: - Color.orange - case .function: - Color.teal - case .method: - Color.blue + case .symbol(let symbol): + switch symbol { + case .class: + Color.purple + case .struct: + Color.purple + case .enum: + Color.purple + case .actor: + Color.purple + case .protocol: + Color.purple + case .extension: + Color.indigo + case .case: + Color.green + case .property: + Color.teal + case .typealias: + Color.orange + case .function: + Color.teal + case .method: + Color.blue + } case .text: Color.gray case .webpage: @@ -173,28 +176,31 @@ struct ReferenceIcon: View { .overlay(alignment: .center) { Group { switch kind { - case .class: - Text("C") - case .struct: - Text("S") - case .enum: - Text("E") - case .actor: - Text("A") - case .protocol: - Text("Pr") - case .extension: - Text("Ex") - case .case: - Text("K") - case .property: - Text("P") - case .typealias: - Text("T") - case .function: - Text("𝑓") - case .method: - Text("M") + case .symbol(let symbol): + switch symbol { + case .class: + Text("C") + case .struct: + Text("S") + case .enum: + Text("E") + case .actor: + Text("A") + case .protocol: + Text("Pr") + case .extension: + Text("Ex") + case .case: + Text("K") + case .property: + Text("P") + case .typealias: + Text("T") + case .function: + Text("𝑓") + case .method: + Text("M") + } case .text: Text("Tx") case .webpage: @@ -225,7 +231,7 @@ struct ReferenceIcon: View { subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .class + kind: .symbol(.class) ), count: 20), chat: .init(initialState: .init(), reducer: { Chat(service: .init()) }) ) @@ -240,35 +246,35 @@ struct ReferenceIcon: View { subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .class + kind: .symbol(.class) ), .init( title: "BotMessage.swift:100-102", subtitle: "/Core/Sources/ChatGPTChatTab/Views", uri: "https://google.com", startLine: nil, - kind: .struct + kind: .symbol(.struct) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .function + kind: .symbol(.function) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .case + kind: .symbol(.case) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .extension + kind: .symbol(.extension) ), .init( title: "ReferenceList", diff --git a/Tool/Package.swift b/Tool/Package.swift index 9c5604bd..21c20f65 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -346,6 +346,16 @@ let package = Package( .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), + .target( + name: "RAGChatAgent", + dependencies: [ + "ChatBasic", + "ChatContextCollector", + "OpenAIService", + "Preferences", + ] + ), + // MARK: - GitHub Copilot .target( diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index d540b4ea..248c8f2b 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -144,7 +144,7 @@ public struct ChatMessage: Equatable, Codable { public init( id: String = UUID().uuidString, senderId: String? = nil, - repinesToo: String? = nil, + responseTo: String? = nil, role: Role, content: String?, name: String? = nil, diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift new file mode 100644 index 00000000..d4464164 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift @@ -0,0 +1,74 @@ +import AIModel +import ChatBasic +import Foundation +import OpenAIService + +public struct ChatAgentConfiguration: Codable { + public var capabilityIds: Set + public var temperature: Double? + public var modelId: String? + public var model: ChatModel? + public var stop: [String]? + public var maxTokens: Int? + public var minimumReplyTokens: Int? + public var runFunctionsAutomatically: Bool? + public var apiKey: String? +} + +public actor RAGChatAgent: ChatAgent { + let configuration: ChatAgentConfiguration + + init(configuration: ChatAgentConfiguration) { + self.configuration = configuration + } + + public func send(_ request: Request) async -> AsyncThrowingStream { + fatalError() +// var continuation: AsyncThrowingStream.Continuation! +// let stream = AsyncThrowingStream { cont in +// continuation = cont +// } +// +// await withTaskCancellationHandler { +// <#code#> +// } onCancel: { +// continuation.finish(throwing: CancellationError()) +// } +// +// return .init { continuation in +// Task { +// let response = try await chatGPTService.send(content: request.text, summary: nil) +// continuation.finish() +// } +// } + } +} + +extension RAGChatAgent { + var allCapabilities: [String: any RAGChatAgentCapability] { + RAGChatAgentCapabilityContainer.capabilities + } + + func capability(for identifier: String) -> (any RAGChatAgentCapability)? { + allCapabilities[identifier] + } +} + +final class ChatFunctionProvider: ChatGPTFunctionProvider { + var functions: [any ChatGPTFunction] = [] + + init() {} + + func removeAll() { + functions = [] + } + + func append(functions others: [any ChatGPTFunction]) { + functions.append(contentsOf: others) + } + + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { + nil + } +} + diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift new file mode 100644 index 00000000..519e3a45 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift @@ -0,0 +1,59 @@ +import ChatBasic +import Foundation + +/// A singleton that stores all the possible capabilities of an ``RAGChatAgent``. +public enum RAGChatAgentCapabilityContainer { + static var capabilities: [String: any RAGChatAgentCapability] = [:] + static func add(_ capability: any RAGChatAgentCapability) { + capabilities[capability.id] = capability + } + + static func add(_ capabilities: [any RAGChatAgentCapability]) { + capabilities.forEach { add($0) } + } +} + +/// A protocol that defines the capability of an ``RAGChatAgent``. +protocol RAGChatAgentCapability: Identifiable { + typealias Request = ChatAgentRequest + typealias Reference = ChatAgentContext.Reference + + /// The name to be displayed to the user. + var name: String { get } + /// The identifier of the capability. + var id: String { get } + /// Fetch the context for a given request. It can return a portion of the context at a time. + func fetchContext(for request: ChatAgentRequest) async -> AsyncStream +} + +public struct ChatAgentContext { + public typealias Reference = ChatMessage.Reference + + /// Extra system prompt to be included in the chat request. + public var extraSystemPrompt: String? + /// References to be included in the chat request. + public var references: [Reference] + /// Functions to be included in the chat request. + public var functions: [any ChatGPTFunction] + + public init( + extraSystemPrompt: String? = nil, + references: [ChatMessage.Reference] = [], + functions: [any ChatGPTFunction] = [] + ) { + self.extraSystemPrompt = extraSystemPrompt + self.references = references + self.functions = functions + } +} + +// MARK: - Default Implementation + +extension RAGChatAgentCapability { + func fetchContext(for request: ChatAgentRequest) async -> AsyncStream { + return AsyncStream { continuation in + continuation.finish() + } + } +} + From 17ae22be5c2a56c876969db97269b6ff28290ac5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 13 Aug 2024 00:22:00 +0800 Subject: [PATCH 060/116] Update ChatMessage.Reference --- Core/Sources/ChatGPTChatTab/Chat.swift | 55 ++++++++++++++++--- Core/Sources/ChatGPTChatTab/ChatPanel.swift | 2 +- .../ChatGPTChatTab/Views/BotMessage.swift | 23 ++++---- Tool/Sources/ChatBasic/ChatMessage.swift | 43 +++++++++------ 4 files changed, 87 insertions(+), 36 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 16185f94..564f2489 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -343,15 +343,7 @@ struct Chat { } }(), text: message.summary ?? message.content ?? "", - references: message.references.map { - .init( - title: $0.title, - subtitle: $0.subTitle, - uri: $0.uri, - startLine: $0.startLine, - kind: $0.kind - ) - } + references: message.references.map(convertReference) )) for call in message.toolCalls ?? [] { @@ -513,3 +505,48 @@ private actor TimedDebounceFunction { } } +private func convertReference( + _ reference: ChatMessage.Reference +) -> DisplayedChatMessage.Reference { + .init( + title: reference.title, + subtitle: { + switch reference.kind { + case let .symbol(_, uri, _, _): + return uri + case let .webpage(uri): + return uri + case let .textFile(uri): + return uri + case let .other(kind): + return kind + case .text: + return reference.content + } + }(), + uri: { + switch reference.kind { + case let .symbol(_, uri, _, _): + return uri + case let .webpage(uri): + return uri + case let .textFile(uri): + return uri + case .other: + return "" + case .text: + return "" + } + }(), + startLine: { + switch reference.kind { + case let .symbol(_, _, startLine, _): + return startLine + default: + return nil + } + }(), + kind: reference.kind + ) +} + diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index ac036cc6..9210a05d 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -504,7 +504,7 @@ struct ChatPanel_Preview: PreviewProvider { subtitle: "Hi Hi Hi Hi", uri: "https://google.com", startLine: nil, - kind: .symbol(.class) + kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil) ), ] ), diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift index ea281dc6..09bcd8e8 100644 --- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift +++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift @@ -139,7 +139,7 @@ struct ReferenceIcon: View { RoundedRectangle(cornerRadius: 4) .fill({ switch kind { - case .symbol(let symbol): + case .symbol(let symbol, _, _, _): switch symbol { case .class: Color.purple @@ -168,6 +168,8 @@ struct ReferenceIcon: View { Color.gray case .webpage: Color.blue + case .textFile: + Color.gray case .other: Color.gray } @@ -176,7 +178,7 @@ struct ReferenceIcon: View { .overlay(alignment: .center) { Group { switch kind { - case .symbol(let symbol): + case .symbol(let symbol, _, _, _): switch symbol { case .class: Text("C") @@ -207,6 +209,8 @@ struct ReferenceIcon: View { Text("Wb") case .other: Text("Ot") + case .textFile: + Text("Tx") } } .font(.system(size: 12).monospaced()) @@ -231,7 +235,7 @@ struct ReferenceIcon: View { subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .symbol(.class) + kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil) ), count: 20), chat: .init(initialState: .init(), reducer: { Chat(service: .init()) }) ) @@ -246,43 +250,42 @@ struct ReferenceIcon: View { subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .symbol(.class) + kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil) ), .init( title: "BotMessage.swift:100-102", subtitle: "/Core/Sources/ChatGPTChatTab/Views", uri: "https://google.com", startLine: nil, - kind: .symbol(.struct) + kind: .symbol(.struct, uri: "https://google.com", startLine: nil, endLine: nil) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .symbol(.function) + kind: .symbol(.function, uri: "https://google.com", startLine: nil, endLine: nil) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .symbol(.case) + kind: .symbol(.case, uri: "https://google.com", startLine: nil, endLine: nil) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .symbol(.extension) + kind: .symbol(.extension, uri: "https://google.com", startLine: nil, endLine: nil) ), .init( title: "ReferenceList", subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100", uri: "https://google.com", startLine: nil, - kind: .webpage + kind: .webpage(uri: "https://google.com") ), ], chat: .init(initialState: .init(), reducer: { Chat(service: .init()) })) } - diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 248c8f2b..a6df9be6 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -1,17 +1,22 @@ import CodableWrappers import Foundation +/// A chat message that can be sent or received. public struct ChatMessage: Equatable, Codable { public typealias ID = String + /// The role of a message. public enum Role: String, Codable, Equatable { case system case user case assistant } + /// A function call that can be made by the bot. public struct FunctionCall: Codable, Equatable { + /// The name of the function. public var name: String + /// Arguments in the format of a JSON string. public var arguments: String public init(name: String, arguments: String) { self.name = name @@ -19,10 +24,14 @@ public struct ChatMessage: Equatable, Codable { } } + /// A tool call that can be made by the bot. public struct ToolCall: Codable, Equatable, Identifiable { public var id: String + /// The type of tool call. public var type: String + /// The actual function call. public var function: FunctionCall + /// The response of the function call. public var response: ToolCallResponse public init( id: String, @@ -37,8 +46,11 @@ public struct ChatMessage: Equatable, Codable { } } + /// The response of a tool call public struct ToolCallResponse: Codable, Equatable { + /// The content of the response. public var content: String + /// The summary of the response to display in UI. public var summary: String? public init(content: String, summary: String?) { self.content = content @@ -46,7 +58,9 @@ public struct ChatMessage: Equatable, Codable { } } + /// A reference to include in a chat message. public struct Reference: Codable, Equatable { + /// The kind of reference. public enum Kind: Codable, Equatable { public enum Symbol: String, Codable { case `class` @@ -61,36 +75,33 @@ public struct ChatMessage: Equatable, Codable { case function case method } - case symbol(Symbol) + /// Code symbol. + case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?) + /// Some text. case text - case webpage - case other(String) + /// A webpage. + case webpage(uri: String) + /// A text file. + case textFile(uri: String) + /// Other kind of reference. + case other(kind: String) } + /// The title of the reference. public var title: String - public var subTitle: String - public var uri: String + /// The content of the reference. public var content: String - public var startLine: Int? - public var endLine: Int? + /// The kind of the reference. @FallbackDecoding public var kind: Kind public init( title: String, - subTitle: String, content: String, - uri: String, - startLine: Int? = nil, - endLine: Int? = nil, kind: Kind ) { self.title = title - self.subTitle = subTitle self.content = content - self.uri = uri - self.startLine = startLine - self.endLine = endLine self.kind = kind } } @@ -167,7 +178,7 @@ public struct ChatMessage: Equatable, Codable { } public struct ReferenceKindFallback: FallbackValueProvider { - public static var defaultValue: ChatMessage.Reference.Kind { .other("Unknown") } + public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") } } public struct ChatMessageRoleFallback: FallbackValueProvider { From 8af71afc292e9854cf9941a61c0c363a6c9f115e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 13 Aug 2024 00:22:35 +0800 Subject: [PATCH 061/116] Add RAGChatAgentConfiguration --- .../RAGChatAgentConfiguration.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift new file mode 100644 index 00000000..dd760942 --- /dev/null +++ b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift @@ -0,0 +1,81 @@ +import CodableWrappers +import Foundation + +public struct RAGChatAgentConfiguration: Codable { + public struct ModelConfiguration: Codable { + public var maxTokens: Int + public var minimumReplyTokens: Int + public var temperature: Double + public var systemPrompt: String + + public init( + maxTokens: Int, + minimumReplyTokens: Int, + temperature: Double, + systemPrompt: String + ) { + self.maxTokens = maxTokens + self.minimumReplyTokens = minimumReplyTokens + self.temperature = temperature + self.systemPrompt = systemPrompt + } + } + + public struct ConversationConfiguration: Codable { + public var maxTurns: Int + public var isConversationIsolated: Bool + public var respondInLanguage: String + + public init(maxTurns: Int, isConversationIsolated: Bool, respondInLanguage: String) { + self.maxTurns = maxTurns + self.isConversationIsolated = isConversationIsolated + self.respondInLanguage = respondInLanguage + } + } + + public enum ServiceProvider: Codable { + case chatModel(id: String) + case extensionService(id: String) + } + + public var id: String + public var name: String + public var serviceProvider: ServiceProvider + @FallbackDecoding + public var capabilityIds: Set + + public var modelConfiguration: ModelConfiguration + public var conversationConfiguration: ConversationConfiguration + var _otherConfigurations: Data + + public init( + id: String, + name: String, + serviceProvider: ServiceProvider, + capabilityIds: Set, + modelConfiguration: ModelConfiguration, + conversationConfiguration: ConversationConfiguration, + otherConfigurations: OtherConfiguration + ) throws { + self.id = id + self.name = name + self.serviceProvider = serviceProvider + self.capabilityIds = capabilityIds + self.modelConfiguration = modelConfiguration + self.conversationConfiguration = conversationConfiguration + _otherConfigurations = try JSONEncoder().encode(otherConfigurations) + } + + public func otherConfigurations( + as: Configuration.Type = Configuration.self + ) throws -> Configuration { + try JSONDecoder().decode(Configuration.self, from: _otherConfigurations) + } + + public mutating func setOtherConfigurations( + _ otherConfigurations: Configuration + ) throws { + _otherConfigurations = try JSONEncoder().encode(otherConfigurations) + } +} + From 94d2a0190f09f7243fca8ce58fa011e9f35b4dae Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 13 Aug 2024 00:22:48 +0800 Subject: [PATCH 062/116] Update RAGChatAgent --- Tool/Sources/RAGChatAgent/RAGChatAgent.swift | 64 ++++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift index d4464164..4cd6b435 100644 --- a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift +++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift @@ -3,48 +3,46 @@ import ChatBasic import Foundation import OpenAIService -public struct ChatAgentConfiguration: Codable { - public var capabilityIds: Set - public var temperature: Double? - public var modelId: String? - public var model: ChatModel? - public var stop: [String]? - public var maxTokens: Int? - public var minimumReplyTokens: Int? - public var runFunctionsAutomatically: Bool? - public var apiKey: String? -} - -public actor RAGChatAgent: ChatAgent { - let configuration: ChatAgentConfiguration +public class RAGChatAgent: ChatAgent { + public let configuration: RAGChatAgentConfiguration - init(configuration: ChatAgentConfiguration) { + public init(configuration: RAGChatAgentConfiguration) { self.configuration = configuration } public func send(_ request: Request) async -> AsyncThrowingStream { - fatalError() -// var continuation: AsyncThrowingStream.Continuation! -// let stream = AsyncThrowingStream { cont in -// continuation = cont -// } -// -// await withTaskCancellationHandler { -// <#code#> -// } onCancel: { -// continuation.finish(throwing: CancellationError()) -// } -// -// return .init { continuation in -// Task { -// let response = try await chatGPTService.send(content: request.text, summary: nil) -// continuation.finish() -// } -// } + let service = getService() + let stream = AsyncThrowingStream { continuation in + let task = Task(priority: .userInitiated) { + do { + let response = try await service.send(content: request.text, summary: nil) + for try await item in response { + if Task.isCancelled { + continuation.finish() + return + } + continuation.yield(.contentToken(item)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + + return stream } } extension RAGChatAgent { + func getService() -> ChatGPTServiceType { + fatalError() + } + var allCapabilities: [String: any RAGChatAgentCapability] { RAGChatAgentCapabilityContainer.capabilities } From 4085fdbc9e02b8d4c65bde6d5d65a548577664f2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Aug 2024 15:38:01 +0800 Subject: [PATCH 063/116] Support create ChatGPTServiceType from RAGChatAgentConfiguration --- Tool/Sources/RAGChatAgent/RAGChatAgent.swift | 31 ++++++++++++--- .../RAGChatAgentConfiguration.swift | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift index 4cd6b435..b506d43e 100644 --- a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift +++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift @@ -11,10 +11,10 @@ public class RAGChatAgent: ChatAgent { } public func send(_ request: Request) async -> AsyncThrowingStream { - let service = getService() let stream = AsyncThrowingStream { continuation in let task = Task(priority: .userInitiated) { do { + let service = try await createService(for: request) let response = try await service.send(content: request.text, summary: nil) for try await item in response { if Task.isCancelled { @@ -28,21 +28,40 @@ public class RAGChatAgent: ChatAgent { continuation.finish(throwing: error) } } - + continuation.onTermination = { _ in task.cancel() } } - + return stream } } extension RAGChatAgent { - func getService() -> ChatGPTServiceType { - fatalError() + func createService(for request: Request) async throws -> ChatGPTServiceType { + guard let chatGPTConfiguration = configuration.chatGPTConfiguration + else { throw CancellationError() } + let functionProvider = ChatFunctionProvider() + let memory = AutoManagedChatGPTMemory( + systemPrompt: configuration.modelConfiguration.systemPrompt, + configuration: chatGPTConfiguration, + functionProvider: functionProvider + ) + + await memory.mutateHistory { messages in + for history in request.history { + messages.append(history) + } + } + + return ChatGPTService( + memory: memory, + configuration: chatGPTConfiguration, + functionProvider: functionProvider + ) } - + var allCapabilities: [String: any RAGChatAgentCapability] { RAGChatAgentCapabilityContainer.capabilities } diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift index dd760942..fc7123e6 100644 --- a/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift +++ b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift @@ -1,5 +1,10 @@ +import AIModel +import ChatBasic import CodableWrappers import Foundation +import OpenAIService +import Preferences +import Keychain public struct RAGChatAgentConfiguration: Codable { public struct ModelConfiguration: Codable { @@ -77,5 +82,38 @@ public struct RAGChatAgentConfiguration: Codable { ) throws { _otherConfigurations = try JSONEncoder().encode(otherConfigurations) } + + var chatGPTConfiguration: ChatGPTConfiguration? { + guard case let .chatModel(id) = serviceProvider else { return nil } + return .init( + model: { + let models = UserDefaults.shared.value(for: \.chatModels) + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) + return models.first { $0.id == id } + ?? models.first + }(), + temperature: modelConfiguration.temperature, + stop: [], + maxTokens: modelConfiguration.maxTokens, + minimumReplyTokens: modelConfiguration.minimumReplyTokens, + runFunctionsAutomatically: false, + shouldEndTextWindow: { _ in false } + ) + } + + struct ChatGPTConfiguration: OpenAIService.ChatGPTConfiguration { + var model: ChatModel? + var temperature: Double + var stop: [String] + var maxTokens: Int + var minimumReplyTokens: Int + var runFunctionsAutomatically: Bool + var shouldEndTextWindow: (String) -> Bool + + var apiKey: String { + guard let name = model?.info.apiKeyName else { return "" } + return (try? Keychain.apiKey.get(name)) ?? "" + } + } } From 7c9958af0293137a5ddbdc3ba7484dcb9602a4d0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Aug 2024 15:40:13 +0800 Subject: [PATCH 064/116] Rename file to LegacyChatGPTService --- .../{ChatGPTService.swift => LegacyChatGPTService.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Tool/Sources/OpenAIService/{ChatGPTService.swift => LegacyChatGPTService.swift} (100%) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift similarity index 100% rename from Tool/Sources/OpenAIService/ChatGPTService.swift rename to Tool/Sources/OpenAIService/LegacyChatGPTService.swift From d4c9c242edff105c587aff74071ffb13bd572415 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Aug 2024 21:59:35 +0800 Subject: [PATCH 065/116] WIP --- .../ChatPlugin/AITerminalChatPlugin.swift | 4 +- Core/Sources/ChatPlugin/AskChatGPT.swift | 2 +- Core/Sources/ChatPlugin/CallAIFunction.swift | 2 +- Core/Sources/ChatPlugin/ChatPlugin.swift | 2 +- .../ChatPlugin/TerminalChatPlugin.swift | 4 +- .../MathChatPlugin/MathChatPlugin.swift | 4 +- .../SearchChatPlugin/SearchChatPlugin.swift | 4 +- .../ShortcutChatPlugin.swift | 4 +- .../ShortcutInputChatPlugin.swift | 4 +- .../ChatService/ChatPluginController.swift | 6 +- Core/Sources/ChatService/ChatService.swift | 6 +- .../ChatModelManagement/ChatModelEdit.swift | 2 +- .../OpenAIPromptToCodeService.swift | 4 +- .../LangChain/ChatModel/OpenAIChat.swift | 2 +- .../APIs/ChatCompletionsAPIBuilder.swift | 129 +++++ .../OpenAIService/ChatGPTService.swift | 530 ++++++++++++++++++ .../OpenAIService/LegacyChatGPTService.swift | 142 +---- Tool/Sources/RAGChatAgent/RAGChatAgent.swift | 4 +- .../ChatGPTServiceFieldTests.swift | 4 +- .../ChatGPTStreamTests.swift | 8 +- 20 files changed, 711 insertions(+), 156 deletions(-) create mode 100644 Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift create mode 100644 Tool/Sources/OpenAIService/ChatGPTService.swift diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift index 6e95f29d..d00990f4 100644 --- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift @@ -6,14 +6,14 @@ public actor AITerminalChatPlugin: ChatPlugin { public static var command: String { "airun" } public nonisolated var name: String { "AI Terminal" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var terminal: TerminalType = Terminal() var isCancelled = false weak var delegate: ChatPluginDelegate? var isStarted = false var command: String? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/ChatPlugin/AskChatGPT.swift index e95deac9..6defedf6 100644 --- a/Core/Sources/ChatPlugin/AskChatGPT.swift +++ b/Core/Sources/ChatPlugin/AskChatGPT.swift @@ -14,7 +14,7 @@ public func askChatGPT( configuration: configuration, functionProvider: NoChatGPTFunctionProvider() ) - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration ) diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/ChatPlugin/CallAIFunction.swift index e29a4d31..8c38e651 100644 --- a/Core/Sources/ChatPlugin/CallAIFunction.swift +++ b/Core/Sources/ChatPlugin/CallAIFunction.swift @@ -18,7 +18,7 @@ func callAIFunction( let argsString = args.joined(separator: ", ") let configuration = UserPreferenceChatGPTConfiguration() .overriding(.init(temperature: 0)) - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: AutoManagedChatGPTMemory( systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.", configuration: configuration, diff --git a/Core/Sources/ChatPlugin/ChatPlugin.swift b/Core/Sources/ChatPlugin/ChatPlugin.swift index 770b0852..d978e265 100644 --- a/Core/Sources/ChatPlugin/ChatPlugin.swift +++ b/Core/Sources/ChatPlugin/ChatPlugin.swift @@ -6,7 +6,7 @@ public protocol ChatPlugin: AnyObject { static var command: String { get } var name: String { get } - init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) + init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) func send(content: String, originalMessage: String) async func cancel() async func stopResponding() async diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index 285b2947..9c975bbc 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift @@ -7,12 +7,12 @@ public actor TerminalChatPlugin: ChatPlugin { public static var command: String { "run" } public nonisolated var name: String { "Terminal" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var terminal: TerminalType = Terminal() var isCancelled = false weak var delegate: ChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 2bfa3846..67e5720f 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -7,11 +7,11 @@ public actor MathChatPlugin: ChatPlugin { public static var command: String { "math" } public nonisolated var name: String { "Math" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var isCancelled = false weak var delegate: ChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift index 99cf6028..1b05168f 100644 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift @@ -6,11 +6,11 @@ public actor SearchChatPlugin: ChatPlugin { public static var command: String { "search" } public nonisolated var name: String { "Search" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var isCancelled = false weak var delegate: ChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift index c6a9bddf..23eb75ec 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -8,12 +8,12 @@ public actor ShortcutChatPlugin: ChatPlugin { public static var command: String { "shortcut" } public nonisolated var name: String { "Shortcut" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var terminal: TerminalType = Terminal() var isCancelled = false weak var delegate: ChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift index 5616f072..8eab91ff 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift @@ -8,12 +8,12 @@ public actor ShortcutInputChatPlugin: ChatPlugin { public static var command: String { "shortcutInput" } public nonisolated var name: String { "Shortcut Input" } - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType var terminal: TerminalType = Terminal() var isCancelled = false weak var delegate: ChatPluginDelegate? - public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) { + public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) { self.chatGPTService = chatGPTService self.delegate = delegate } diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift index 99a7c629..82a35662 100644 --- a/Core/Sources/ChatService/ChatPluginController.swift +++ b/Core/Sources/ChatService/ChatPluginController.swift @@ -4,12 +4,12 @@ import Foundation import OpenAIService final class ChatPluginController { - let chatGPTService: any ChatGPTServiceType + let chatGPTService: any LegacyChatGPTServiceType let plugins: [String: ChatPlugin.Type] var runningPlugin: ChatPlugin? weak var chatService: ChatService? - init(chatGPTService: any ChatGPTServiceType, plugins: [ChatPlugin.Type]) { + init(chatGPTService: any LegacyChatGPTServiceType, plugins: [ChatPlugin.Type]) { self.chatGPTService = chatGPTService var all = [String: ChatPlugin.Type]() for plugin in plugins { @@ -18,7 +18,7 @@ final class ChatPluginController { self.plugins = all } - convenience init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) { + convenience init(chatGPTService: any LegacyChatGPTServiceType, plugins: ChatPlugin.Type...) { self.init(chatGPTService: chatGPTService, plugins: plugins) } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 4bb74639..029fface 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -10,7 +10,7 @@ public final class ChatService: ObservableObject { public let memory: ContextAwareAutoManagedChatGPTMemory public let configuration: OverridingChatGPTConfiguration - public let chatGPTService: any ChatGPTServiceType + public let chatGPTService: any LegacyChatGPTServiceType public var allPluginCommands: [String] { allPlugins.map { $0.command } } @Published public internal(set) var chatHistory: [ChatMessage] = [] @Published public internal(set) var isReceivingMessage = false @@ -22,7 +22,7 @@ public final class ChatService: ObservableObject { let pluginController: ChatPluginController var cancellable = Set() - init( + init( memory: ContextAwareAutoManagedChatGPTMemory, configuration: OverridingChatGPTConfiguration, chatGPTService: T @@ -53,7 +53,7 @@ public final class ChatService: ObservableObject { self.init( memory: memory, configuration: configuration, - chatGPTService: ChatGPTService( + chatGPTService: LegacyChatGPTService( memory: memory, configuration: extraConfiguration, functionProvider: memory.functionProvider diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 7450105e..95914577 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -85,7 +85,7 @@ struct ChatModelEdit { let model = ChatModel(state: state) return .run { send in do { - let service = ChatGPTService( + let service = LegacyChatGPTService( configuration: UserPreferenceChatGPTConfiguration() .overriding { $0.model = model diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index ca39a2fc..c10ae7b6 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -5,7 +5,7 @@ import SuggestionBasic import XcodeInspector public final class OpenAIPromptToCodeService: PromptToCodeServiceType { - var service: (any ChatGPTServiceType)? + var service: (any LegacyChatGPTServiceType)? public init() {} @@ -181,7 +181,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { configuration: configuration, functionProvider: NoChatGPTFunctionProvider() ) - let chatGPTService = ChatGPTService( + let chatGPTService = LegacyChatGPTService( memory: memory, configuration: configuration ) diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift index 83bd827a..2023e3c9 100644 --- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift +++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift @@ -26,7 +26,7 @@ public struct OpenAIChat: ChatModel { ) async throws -> ChatMessage { let memory = memory ?? EmptyChatGPTMemory() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift new file mode 100644 index 00000000..3ec1bd16 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift @@ -0,0 +1,129 @@ +import AIModel +import ChatBasic +import Dependencies +import Foundation + +protocol ChatCompletionsAPIBuilder { + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody, + prompt: ChatGPTPrompt + ) -> any ChatCompletionsStreamAPI + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody, + prompt: ChatGPTPrompt + ) -> any ChatCompletionsAPI +} + +struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody, + prompt: ChatGPTPrompt + ) -> any ChatCompletionsStreamAPI { + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + } + } + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody, + prompt: ChatGPTPrompt + ) -> any ChatCompletionsAPI { + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + } + } +} + +struct ChatCompletionsAPIBuilderDependencyKey: DependencyKey { + static var liveValue = DefaultChatCompletionsAPIBuilder() +} + +extension DependencyValues { + var chatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { + get { self[ChatCompletionsAPIBuilderDependencyKey.self] } + set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue } + } +} diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift new file mode 100644 index 00000000..1de2f3cc --- /dev/null +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -0,0 +1,530 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Dependencies +import Foundation +import IdentifiedCollections +import Preferences + +public enum ChatGPTResponse { + case status(String) + case partialText(String) + case partialToolCall(ChatMessage.ToolCall) +} + +public struct ChatGPTRequest { + public var history: [ChatMessage] + public var message: ChatMessage +} + +public typealias ChatGPTResponseStream = AsyncThrowingStream + +public extension ChatGPTResponseStream { + func asText() async throws -> String { + var text = "" + for try await case let .partialText(response) in self { + text += response + } + return text + } +} + +public protocol ChatGPTServiceType { + typealias Request = ChatGPTRequest + typealias Response = ChatGPTResponse + var configuration: ChatGPTConfiguration { get set } + func send(_ request: Request) async -> ChatGPTResponseStream +} + +public class ChatGPTService: ChatGPTServiceType { + public var configuration: ChatGPTConfiguration + public var functionProvider: ChatGPTFunctionProvider + + var runningTask: Task? + var buildCompletionStreamAPI: ChatCompletionsStreamAPIBuilder = { + apiKey, model, endpoint, requestBody, prompt in + + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + } + } + + var buildCompletionAPI: ChatCompletionsAPIBuilder = { + apiKey, model, endpoint, requestBody, prompt in + + if model.id == "com.github.copilot" { + return BuiltinExtensionChatCompletionsService( + extensionIdentifier: model.id, + requestBody: requestBody + ) + } + + switch model.format { + case .googleAI: + return GoogleAIChatCompletionsService( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt, + baseURL: endpoint.absoluteString + ) + case .openAI, .openAICompatible, .azureOpenAI: + return OpenAIChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .ollama: + return OllamaChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + case .claude: + return ClaudeChatCompletionsService( + apiKey: apiKey, + model: model, + endpoint: endpoint, + requestBody: requestBody + ) + } + } + + public init( + configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), + functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider() + ) { + self.configuration = configuration + self.functionProvider = functionProvider + } + + @Dependency(\.uuid) var uuid + @Dependency(\.date) var date + + /// Send a message and stream the reply. + public func send(_: Request) async -> ChatGPTResponseStream { + return Debugger.$id.withValue(.init()) { + AsyncThrowingStream { continuation in + let task = Task(priority: .userInitiated) { + do { + var pendingToolCalls = [ChatMessage.ToolCall]() + var sourceMessageId = "" + var isInitialCall = true + loop: while !pendingToolCalls.isEmpty || isInitialCall { + try Task.checkCancellation() + isInitialCall = false + for toolCall in pendingToolCalls { + if !configuration.runFunctionsAutomatically { + break loop + } + for await response in runFunctionCall(toolCall) { + continuation.yield(.partialToolCall(response)) + } + } + sourceMessageId = uuid() + .uuidString + String(date().timeIntervalSince1970) + let stream = try await sendMemory(proposedId: sourceMessageId) + + #if DEBUG + var reply = "" + #endif + + for try await content in stream { + try Task.checkCancellation() + switch content { + case let .text(text): + continuation.yield(text) + #if DEBUG + reply.append(text) + #endif + + case let .toolCall(toolCall): + await prepareFunctionCall( + toolCall, + sourceMessageId: sourceMessageId + ) + } + } + + pendingToolCalls = await memory.history + .last { $0.id == sourceMessageId }? + .toolCalls ?? [] + + #if DEBUG + Debugger.didReceiveResponse(content: reply) + #endif + } + + #if DEBUG + Debugger.didFinish() + #endif + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + } +} + +// - MARK: Internal + +extension ChatGPTService { + enum StreamContent { + case text(String) + case toolCall(ChatMessage.ToolCall) + } + + enum FunctionCallResult { + case status(String) + case output(String) + } + + /// Send the memory as prompt to ChatGPT, with stream enabled. + func sendMemory(request: Request) async throws -> AsyncThrowingStream { + let prompt = await memory.generatePrompt() + + guard let model = configuration.model else { + throw ChatGPTServiceError.chatModelNotAvailable + } + guard let url = URL(string: configuration.endpoint) else { + throw ChatGPTServiceError.endpointIncorrect + } + + let requestBody = createRequestBody(prompt: prompt, model: model, stream: true) + + let api = buildCompletionStreamAPI( + configuration.apiKey, + model, + url, + requestBody, + prompt + ) + + #if DEBUG + Debugger.didSendRequestBody(body: requestBody) + #endif + + return AsyncThrowingStream { continuation in + let task = Task { + do { + await memory.streamMessage( + id: proposedId, + role: .assistant, + references: prompt.references + ) + let chunks = try await api() + for try await chunk in chunks { + if Task.isCancelled { + throw CancellationError() + } + guard let delta = chunk.message else { continue } + + // The api will always return a function call with JSON object. + // The first round will contain the function name and an empty argument. + // e.g. {"name":"weather","arguments":""} + // The other rounds will contain part of the arguments. + let toolCalls = delta.toolCalls? + .reduce(into: [Int: ChatMessage.ToolCall]()) { + $0[$1.index ?? 0] = ChatMessage.ToolCall( + id: $1.id ?? "", + type: $1.type ?? "", + function: .init( + name: $1.function?.name ?? "", + arguments: $1.function?.arguments ?? "" + ) + ) + } + + await memory.streamMessage( + id: proposedId, + role: delta.role?.asChatMessageRole, + content: delta.content, + toolCalls: toolCalls + ) + + if let toolCalls { + for toolCall in toolCalls.values { + continuation.yield(.toolCall(toolCall)) + } + } + + if let content = delta.content { + continuation.yield(.text(content)) + } + + try await Task.sleep(nanoseconds: 3_000_000) + } + + continuation.finish() + } catch let error as CancellationError { + continuation.finish(throwing: error) + } catch let error as NSError where error.code == NSURLErrorCancelled { + continuation.finish(throwing: error) + } catch { + await memory.appendMessage(.init( + role: .assistant, + content: error.localizedDescription + )) + continuation.finish(throwing: error) + } + } + + runningTask = task + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// When a function call is detected, but arguments are not yet ready, we can call this + /// to report the status. + func prepareFunctionCall( + _ call: ChatMessage.ToolCall + ) async -> AsyncStream { + return .init { continuation in + guard let function = functionProvider.function(named: call.function.name) else { + continuation.finish() + } + let task = Task { + await function.prepare { summary in + continuation.yield(.status(summary)) + } + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// Run a function call from the bot. + @discardableResult + func runFunctionCall(_ call: ChatMessage.ToolCall) async -> AsyncStream { + #if DEBUG + Debugger.didReceiveFunction(name: call.function.name, arguments: call.function.arguments) + #endif + + return .init { continuation in + + let task = Task { + guard let function = functionProvider.function(named: call.function.name) else { + let response = await fallbackFunctionCall(call) + continuation.yield(.output(response)) + continuation.finish() + return + } + + do { + // Run the function + let result = try await function + .call(argumentsJsonString: call.function.arguments) { summary in + continuation.yield(.status(summary)) + } + + #if DEBUG + Debugger.didReceiveFunctionResult(result: result.botReadableContent) + #endif + + continuation.yield(.output(result.botReadableContent)) + continuation.finish() + } catch { + // For errors, use the error message as the result. + let content = "Error: \(error.localizedDescription)" + + #if DEBUG + Debugger.didReceiveFunctionResult(result: content) + #endif + + continuation.yield(.output(content)) + continuation.finish() + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// Mock a function call result when the bot is calling a function that is not implemented. + func fallbackFunctionCall( + _ call: ChatMessage.ToolCall + ) async -> String { + let service = ChatGPTService( + configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( + temperature: 0 + )), + functionProvider: NoChatGPTFunctionProvider() + ) + + let stream = await service.send(.init( + history: [ + .init(role: .system, content: { + if call.function.name == "python" { + return """ + Act like a Python interpreter. + I will give you Python code and you will execute it. + Reply with output of the code and tell me it's an answer generated by LLM. + """ + } else { + return """ + You are a function simulator. Your name is \(call.function.name). + Act like a function. + I will send you the arguments. + Reply with output of the function and tell me it's an answer generated by LLM. + """ + } + }()), + ], + message: .init(role: .user, content: """ + \(call.function.arguments) + """) + )) + + do { + return try await stream.asText() + } catch { + return error.localizedDescription + } + } + + func createRequestBody( + prompt: ChatGPTPrompt, + model: ChatModel, + stream: Bool + ) -> ChatCompletionsRequestBody { + let serviceSupportsFunctionCalling = switch model.format { + case .openAI, .openAICompatible, .azureOpenAI: + model.info.supportsFunctionCalling + case .ollama, .googleAI, .claude: + false + } + + let messages = prompt.history.flatMap { chatMessage in + var all = [ChatCompletionsRequestBody.Message]() + all.append(ChatCompletionsRequestBody.Message( + role: { + switch chatMessage.role { + case .system: .system + case .user: .user + case .assistant: .assistant + } + }(), + content: chatMessage.content ?? "", + name: chatMessage.name, + toolCalls: { + if serviceSupportsFunctionCalling { + chatMessage.toolCalls?.map { + .init( + id: $0.id, + type: $0.type, + function: .init( + name: $0.function.name, + arguments: $0.function.arguments + ) + ) + } + } else { + nil + } + }() + )) + + for call in chatMessage.toolCalls ?? [] { + if serviceSupportsFunctionCalling { + all.append(ChatCompletionsRequestBody.Message( + role: .tool, + content: call.response.content, + toolCallId: call.id + )) + } else { + all.append(ChatCompletionsRequestBody.Message( + role: .user, + content: call.response.content + )) + } + } + + return all + } + + let remainingTokens = prompt.remainingTokenCount + + let requestBody = ChatCompletionsRequestBody( + model: model.info.modelName, + messages: messages, + temperature: configuration.temperature, + stream: stream, + stop: configuration.stop.isEmpty ? nil : configuration.stop, + maxTokens: maxTokenForReply( + maxToken: model.info.maxTokens, + remainingTokens: remainingTokens + ), + toolChoice: serviceSupportsFunctionCalling + ? functionProvider.functionCallStrategy + : nil, + tools: serviceSupportsFunctionCalling + ? functionProvider.functions.map { + .init(function: ChatGPTFunctionSchema( + name: $0.name, + description: $0.description, + parameters: $0.argumentSchema + )) + } + : [] + ) + + return requestBody + } +} + +extension ChatGPTService { + func changeBuildCompletionStreamAPI(_ builder: @escaping ChatCompletionsStreamAPIBuilder) { + buildCompletionStreamAPI = builder + } +} + diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 669fae10..3e5863ff 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -6,7 +6,8 @@ import Foundation import IdentifiedCollections import Preferences -public protocol ChatGPTServiceType { +@available(*, deprecated, message: "Use ChatGPTServiceType instead.") +public protocol LegacyChatGPTServiceType { var memory: ChatGPTMemory { get set } var configuration: ChatGPTConfiguration { get set } func send(content: String, summary: String?) async throws -> AsyncThrowingStream @@ -66,113 +67,13 @@ public struct ChatGPTError: Error, Codable, LocalizedError { } } -typealias ChatCompletionsStreamAPIBuilder = ( - String, - ChatModel, - URL, - ChatCompletionsRequestBody, - ChatGPTPrompt -) -> any ChatCompletionsStreamAPI - -typealias ChatCompletionsAPIBuilder = ( - String, - ChatModel, - URL, - ChatCompletionsRequestBody, - ChatGPTPrompt -) -> any ChatCompletionsAPI - -public class ChatGPTService: ChatGPTServiceType { +@available(*, deprecated, message: "Use ChatGPTServiceType instead.") +public class LegacyChatGPTService: LegacyChatGPTServiceType { public var memory: ChatGPTMemory public var configuration: ChatGPTConfiguration public var functionProvider: ChatGPTFunctionProvider var runningTask: Task? - var buildCompletionStreamAPI: ChatCompletionsStreamAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - - if model.id == "com.github.copilot" { - return BuiltinExtensionChatCompletionsService( - extensionIdentifier: model.id, - requestBody: requestBody - ) - } - - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - } - } - - var buildCompletionAPI: ChatCompletionsAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - - if model.id == "com.github.copilot" { - return BuiltinExtensionChatCompletionsService( - extensionIdentifier: model.id, - requestBody: requestBody - ) - } - - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - } - } public init( memory: ChatGPTMemory = AutoManagedChatGPTMemory( @@ -190,6 +91,7 @@ public class ChatGPTService: ChatGPTServiceType { @Dependency(\.uuid) var uuid @Dependency(\.date) var date + @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder /// Send a message and stream the reply. public func send( @@ -327,7 +229,7 @@ public class ChatGPTService: ChatGPTServiceType { // - MARK: Internal -extension ChatGPTService { +extension LegacyChatGPTService { enum StreamContent { case text(String) case toolCall(ChatMessage.ToolCall) @@ -346,12 +248,12 @@ extension ChatGPTService { let requestBody = createRequestBody(prompt: prompt, model: model, stream: true) - let api = buildCompletionStreamAPI( - configuration.apiKey, - model, - url, - requestBody, - prompt + let api = chatCompletionsAPIBuilder.buildStreamAPI( + model: model, + endpoint: url, + apiKey: configuration.apiKey, + requestBody: requestBody, + prompt: prompt ) #if DEBUG @@ -445,12 +347,12 @@ extension ChatGPTService { let requestBody = createRequestBody(prompt: prompt, model: model, stream: false) - let api = buildCompletionAPI( - configuration.apiKey, - model, - url, - requestBody, - prompt + let api = chatCompletionsAPIBuilder.buildNonStreamAPI( + model: model, + endpoint: url, + apiKey: configuration.apiKey, + requestBody: requestBody, + prompt: prompt ) #if DEBUG @@ -578,7 +480,7 @@ extension ChatGPTService { } }()) - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( temperature: 0 @@ -694,12 +596,6 @@ extension ChatGPTService { } } -extension ChatGPTService { - func changeBuildCompletionStreamAPI(_ builder: @escaping ChatCompletionsStreamAPIBuilder) { - buildCompletionStreamAPI = builder - } -} - func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } return min(maxToken / 2, remainingTokens) diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift index b506d43e..5e7306e9 100644 --- a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift +++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift @@ -39,7 +39,7 @@ public class RAGChatAgent: ChatAgent { } extension RAGChatAgent { - func createService(for request: Request) async throws -> ChatGPTServiceType { + func createService(for request: Request) async throws -> LegacyChatGPTServiceType { guard let chatGPTConfiguration = configuration.chatGPTConfiguration else { throw CancellationError() } let functionProvider = ChatFunctionProvider() @@ -55,7 +55,7 @@ extension RAGChatAgent { } } - return ChatGPTService( + return LegacyChatGPTService( memory: memory, configuration: chatGPTConfiguration, functionProvider: functionProvider diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift index 99646988..5bc6902a 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift @@ -5,7 +5,7 @@ final class ChatGPTServiceFieldTests: XCTestCase { let skip = true func test_calling_the_api() async throws { - let service = ChatGPTService() + let service = LegacyChatGPTService() if skip { return } @@ -22,7 +22,7 @@ final class ChatGPTServiceFieldTests: XCTestCase { } func test_calling_the_api_with_function_calling() async throws { - let service = ChatGPTService() + let service = LegacyChatGPTService() if skip { return } diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 5f7c50db..dafaa527 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -10,7 +10,7 @@ final class ChatGPTStreamTests: XCTestCase { $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) } let functionProvider = NoChatGPTFunctionProvider() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider @@ -71,7 +71,7 @@ final class ChatGPTStreamTests: XCTestCase { $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) } let functionProvider = FunctionProvider() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider @@ -166,7 +166,7 @@ final class ChatGPTStreamTests: XCTestCase { $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) } let functionProvider = FunctionProvider() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider @@ -304,7 +304,7 @@ final class ChatGPTStreamTests: XCTestCase { ) } let functionProvider = FunctionProvider() - let service = ChatGPTService( + let service = LegacyChatGPTService( memory: memory, configuration: configuration, functionProvider: functionProvider From a50cd211fb2f2ed0eea5e8c2c9df5af23573e757 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 18 Aug 2024 16:39:24 +0800 Subject: [PATCH 066/116] Update ChatCompletionsAPIBuilder to not take the prompt --- .../APIs/ChatCompletionsAPIBuilder.swift | 16 ++---- .../APIs/GoogleAIChatCompletionsService.swift | 57 ++++++++----------- .../OpenAIService/LegacyChatGPTService.swift | 6 +- 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift index 3ec1bd16..f114b32d 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift @@ -8,16 +8,14 @@ protocol ChatCompletionsAPIBuilder { model: ChatModel, endpoint: URL, apiKey: String, - requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt + requestBody: ChatCompletionsRequestBody ) -> any ChatCompletionsStreamAPI func buildNonStreamAPI( model: ChatModel, endpoint: URL, apiKey: String, - requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt + requestBody: ChatCompletionsRequestBody ) -> any ChatCompletionsAPI } @@ -26,8 +24,7 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { model: ChatModel, endpoint: URL, apiKey: String, - requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt + requestBody: ChatCompletionsRequestBody ) -> any ChatCompletionsStreamAPI { if model.id == "com.github.copilot" { return BuiltinExtensionChatCompletionsService( @@ -42,7 +39,6 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { apiKey: apiKey, model: model, requestBody: requestBody, - prompt: prompt, baseURL: endpoint.absoluteString ) case .openAI, .openAICompatible, .azureOpenAI: @@ -73,8 +69,7 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { model: ChatModel, endpoint: URL, apiKey: String, - requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt + requestBody: ChatCompletionsRequestBody ) -> any ChatCompletionsAPI { if model.id == "com.github.copilot" { return BuiltinExtensionChatCompletionsService( @@ -89,7 +84,6 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { apiKey: apiKey, model: model, requestBody: requestBody, - prompt: prompt, baseURL: endpoint.absoluteString ) case .openAI, .openAICompatible, .azureOpenAI: @@ -118,7 +112,7 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { } struct ChatCompletionsAPIBuilderDependencyKey: DependencyKey { - static var liveValue = DefaultChatCompletionsAPIBuilder() + static var liveValue: ChatCompletionsAPIBuilder = DefaultChatCompletionsAPIBuilder() } extension DependencyValues { diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift index 2770b6e2..e81609f9 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift @@ -7,20 +7,17 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA let apiKey: String let model: ChatModel var requestBody: ChatCompletionsRequestBody - let prompt: ChatGPTPrompt let baseURL: String init( apiKey: String, model: ChatModel, requestBody: ChatCompletionsRequestBody, - prompt: ChatGPTPrompt, baseURL: String ) { self.apiKey = apiKey self.model = model self.requestBody = requestBody - self.prompt = prompt self.baseURL = baseURL } @@ -36,9 +33,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA ? .init() : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) - let history = prompt.googleAICompatible.history.map { message in - ModelContent(message) - } + let history = Self.convertMessages(requestBody.messages) do { let response = try await aiModel.generateContent(history) @@ -86,7 +81,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA ? .init() : .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion) ) - let history = prompt.googleAICompatible.history.map { message in + let history = requestBody.messages.map { message in ModelContent(message) } @@ -135,15 +130,15 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA return stream } -} -extension ChatGPTPrompt { - var googleAICompatible: ChatGPTPrompt { - var history = self.history - var reformattedHistory = [ChatMessage]() + static func convertMessages( + _ messages: [ChatCompletionsRequestBody.Message] + ) -> [ModelContent] { + var history = messages + var reformattedHistory = [ChatCompletionsRequestBody.Message]() // We don't want to combine the new user message with others. - let newUserMessage: ChatMessage? = if history.last?.role == .user { + let newUserMessage: ChatCompletionsRequestBody.Message? = if history.last?.role == .user { history.removeLast() } else { nil @@ -154,7 +149,6 @@ extension ChatGPTPrompt { guard lastIndex >= 0 else { // first message if message.role == .system { reformattedHistory.append(.init( - id: message.id, role: .user, content: ModelContent.convertContent(of: message) )) @@ -174,8 +168,7 @@ extension ChatGPTPrompt { if ModelContent.convertRole(lastMessage.role) == ModelContent .convertRole(message.role) { - let newMessage = ChatMessage( - id: message.id, + let newMessage = ChatCompletionsRequestBody.Message( role: message.role == .assistant ? .assistant : .user, content: """ \(ModelContent.convertContent(of: lastMessage)) @@ -197,7 +190,7 @@ extension ChatGPTPrompt { .convertRole(newUserMessage.role) { // Add dummy message - let dummyMessage = ChatMessage( + let dummyMessage = ChatCompletionsRequestBody.Message( role: .assistant, content: "OK" ) @@ -206,47 +199,47 @@ extension ChatGPTPrompt { reformattedHistory.append(newUserMessage) } - return .init( - history: reformattedHistory, - references: references, - remainingTokenCount: remainingTokenCount - ) + return reformattedHistory.map(ModelContent.init) } } extension ModelContent { - static func convertRole(_ role: ChatMessage.Role) -> String { + static func convertRole(_ role: ChatCompletionsRequestBody.Message.Role) -> String { switch role { - case .user, .system: + case .user, .system, .tool: return "user" case .assistant: return "model" } } - static func convertContent(of message: ChatMessage) -> String { + static func convertContent(of message: ChatCompletionsRequestBody.Message) -> String { switch message.role { case .system: - return "System Prompt:\n\(message.content ?? " ")" + return "System Prompt:\n\(message.content)" case .user: - return message.content ?? " " + return message.content + case .tool: + return """ + Result of function ID: \(message.toolCallId ?? "") + \(message.content) + """ case .assistant: if let toolCalls = message.toolCalls { return toolCalls.map { call in - let response = call.response return """ + Function ID: \(call.id) Call function: \(call.function.name) - Arguments: \(call.function.arguments) - Result: \(response.content) + Arguments: \(call.function.arguments ?? "{}") """ }.joined(separator: "\n") } else { - return message.content ?? " " + return message.content } } } - init(_ message: ChatMessage) { + init(_ message: ChatCompletionsRequestBody.Message) { let role = Self.convertRole(message.role) let parts = [ModelContent.Part.text(Self.convertContent(of: message))] self = .init(role: role, parts: parts) diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 3e5863ff..47fe151b 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -252,8 +252,7 @@ extension LegacyChatGPTService { model: model, endpoint: url, apiKey: configuration.apiKey, - requestBody: requestBody, - prompt: prompt + requestBody: requestBody ) #if DEBUG @@ -351,8 +350,7 @@ extension LegacyChatGPTService { model: model, endpoint: url, apiKey: configuration.apiKey, - requestBody: requestBody, - prompt: prompt + requestBody: requestBody ) #if DEBUG From a883763611eb2be90781936ab79335b8bd4d451f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 18 Aug 2024 22:46:01 +0800 Subject: [PATCH 067/116] Add ChatGPTService --- Tool/Sources/ChatBasic/ChatAgent.swift | 13 +- Tool/Sources/ChatBasic/ChatGPTFunction.swift | 22 +- .../OpenAIService/ChatGPTService.swift | 372 ++++++----- .../OpenAIService/LegacyChatGPTService.swift | 1 - ...edChatGPTMemoryRetrievedContentTests.swift | 4 - .../ChatGPTServiceTests.swift | 580 ++++++++++++++++++ .../ChatGPTStreamTests.swift | 50 +- ...matPromptToBeGoogleAICompatibleTests.swift | 322 +++++----- 8 files changed, 971 insertions(+), 393 deletions(-) create mode 100644 Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 9f74517c..30be4caa 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -1,16 +1,21 @@ import Foundation public enum ChatAgentResponse { + public enum Content { + case text(String) + case modification + } + /// Post the status of the current message. case status(String) - /// Send a token of the text message to the current message. - case contentToken(String) + /// Update the text message to the current message. + case content([Content]) /// Update the attachments of the current message. case attachments([URL]) /// Update the references of the current message. case references([ChatMessage.Reference]) - /// End the message. The next contents will be sent as a new message. - case finishMessage + /// End the current message. The next contents will be sent as a new message. + case startNewMessage } public struct ChatAgentRequest { diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 1131c247..0e2690da 100644 --- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -43,14 +43,18 @@ public extension ChatGPTFunction { argumentsJsonString: String, reportProgress: @escaping ReportProgress ) async throws -> Result { - do { - let arguments = try JSONDecoder() - .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data()) - return try await call(arguments: arguments, reportProgress: reportProgress) - } catch { - await reportProgress("Error: Failed to decode arguments. \(error.localizedDescription)") - throw error - } + let arguments = try await { + do { + return try JSONDecoder() + .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data()) + } catch { + await reportProgress( + "Error: Failed to decode arguments. \(error.localizedDescription)" + ) + throw error + } + }() + return try await call(arguments: arguments, reportProgress: reportProgress) } } @@ -85,7 +89,7 @@ public extension ChatGPTArgumentsCollectingFunction { assertionFailure("This function is only used to get a structured output from the bot.") return "" } - + @available( *, deprecated, diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 1de2f3cc..c1a84e23 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -6,15 +6,10 @@ import Foundation import IdentifiedCollections import Preferences -public enum ChatGPTResponse { +public enum ChatGPTResponse: Equatable { case status(String) case partialText(String) - case partialToolCall(ChatMessage.ToolCall) -} - -public struct ChatGPTRequest { - public var history: [ChatMessage] - public var message: ChatMessage + case toolCalls([ChatMessage.ToolCall]) } public typealias ChatGPTResponseStream = AsyncThrowingStream @@ -27,106 +22,34 @@ public extension ChatGPTResponseStream { } return text } + + func asToolCalls() async throws -> [ChatMessage.ToolCall] { + var toolCalls = [ChatMessage.ToolCall]() + for try await case let .toolCalls(calls) in self { + toolCalls.append(contentsOf: calls) + } + return toolCalls + } + + func asArray() async throws -> [ChatGPTResponse] { + var responses = [ChatGPTResponse]() + for try await response in self { + responses.append(response) + } + return responses + } } public protocol ChatGPTServiceType { - typealias Request = ChatGPTRequest typealias Response = ChatGPTResponse var configuration: ChatGPTConfiguration { get set } - func send(_ request: Request) async -> ChatGPTResponseStream + func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream } public class ChatGPTService: ChatGPTServiceType { public var configuration: ChatGPTConfiguration public var functionProvider: ChatGPTFunctionProvider - var runningTask: Task? - var buildCompletionStreamAPI: ChatCompletionsStreamAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - - if model.id == "com.github.copilot" { - return BuiltinExtensionChatCompletionsService( - extensionIdentifier: model.id, - requestBody: requestBody - ) - } - - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - } - } - - var buildCompletionAPI: ChatCompletionsAPIBuilder = { - apiKey, model, endpoint, requestBody, prompt in - - if model.id == "com.github.copilot" { - return BuiltinExtensionChatCompletionsService( - extensionIdentifier: model.id, - requestBody: requestBody - ) - } - - switch model.format { - case .googleAI: - return GoogleAIChatCompletionsService( - apiKey: apiKey, - model: model, - requestBody: requestBody, - prompt: prompt, - baseURL: endpoint.absoluteString - ) - case .openAI, .openAICompatible, .azureOpenAI: - return OpenAIChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .ollama: - return OllamaChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - case .claude: - return ClaudeChatCompletionsService( - apiKey: apiKey, - model: model, - endpoint: endpoint, - requestBody: requestBody - ) - } - } - public init( configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider() @@ -137,58 +60,91 @@ public class ChatGPTService: ChatGPTServiceType { @Dependency(\.uuid) var uuid @Dependency(\.date) var date - - /// Send a message and stream the reply. - public func send(_: Request) async -> ChatGPTResponseStream { + @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder + + /// Send the memory and stream the reply. While it's returning the results in a + /// ``ChatGPTResponseStream``, it's also streaming the results to the memory. + /// + /// If ``ChatGPTConfiguration/runFunctionsAutomatically`` is enabled, the service will handle + /// the tool calls inside the function. Otherwise, it will return the tool calls to the caller. + public func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream { return Debugger.$id.withValue(.init()) { - AsyncThrowingStream { continuation in + ChatGPTResponseStream { continuation in let task = Task(priority: .userInitiated) { do { var pendingToolCalls = [ChatMessage.ToolCall]() var sourceMessageId = "" var isInitialCall = true + loop: while !pendingToolCalls.isEmpty || isInitialCall { try Task.checkCancellation() isInitialCall = false - for toolCall in pendingToolCalls { - if !configuration.runFunctionsAutomatically { - break loop - } - for await response in runFunctionCall(toolCall) { - continuation.yield(.partialToolCall(response)) + + var functionCallResponses = [ChatCompletionsRequestBody.Message]() + + if !pendingToolCalls.isEmpty { + if configuration.runFunctionsAutomatically { + for toolCall in pendingToolCalls { + for await response in await runFunctionCall( + toolCall, + memory: memory, + sourceMessageId: sourceMessageId + ) { + switch response { + case let .output(output): + functionCallResponses.append(.init( + role: .tool, + content: output, + toolCallId: toolCall.id + )) + case let .status(status): + continuation.yield(.status(status)) + } + } + } + } else { + if !configuration.runFunctionsAutomatically { + continuation.yield(.toolCalls(pendingToolCalls)) + continuation.finish() + return + } } } - sourceMessageId = uuid() - .uuidString + String(date().timeIntervalSince1970) - let stream = try await sendMemory(proposedId: sourceMessageId) - #if DEBUG - var reply = "" - #endif + sourceMessageId = uuid().uuidString + let stream = try await sendRequest( + memory: memory, + proposedMessageId: sourceMessageId + ) for try await content in stream { try Task.checkCancellation() switch content { - case let .text(text): - continuation.yield(text) - #if DEBUG - reply.append(text) - #endif - - case let .toolCall(toolCall): - await prepareFunctionCall( - toolCall, - sourceMessageId: sourceMessageId - ) + case let .partialText(text): + continuation.yield(.partialText(text)) + + case let .partialToolCalls(toolCalls): + guard configuration.runFunctionsAutomatically else { break } + for toolCall in toolCalls.keys.sorted() { + if let toolCallValue = toolCalls[toolCall] { + for await status in await prepareFunctionCall( + toolCallValue, + memory: memory, + sourceMessageId: sourceMessageId + ) { + continuation.yield(.status(status)) + } + } + } } } - pendingToolCalls = await memory.history - .last { $0.id == sourceMessageId }? - .toolCalls ?? [] + let replyMessage = await memory.history + .last { $0.id == sourceMessageId } + pendingToolCalls = replyMessage?.toolCalls ?? [] #if DEBUG - Debugger.didReceiveResponse(content: reply) + Debugger.didReceiveResponse(content: replyMessage?.content ?? "") #endif } @@ -212,8 +168,8 @@ public class ChatGPTService: ChatGPTServiceType { extension ChatGPTService { enum StreamContent { - case text(String) - case toolCall(ChatMessage.ToolCall) + case partialText(String) + case partialToolCalls([Int: ChatMessage.ToolCall]) } enum FunctionCallResult { @@ -222,7 +178,10 @@ extension ChatGPTService { } /// Send the memory as prompt to ChatGPT, with stream enabled. - func sendMemory(request: Request) async throws -> AsyncThrowingStream { + func sendRequest( + memory: ChatGPTMemory, + proposedMessageId: String + ) async throws -> AsyncThrowingStream { let prompt = await memory.generatePrompt() guard let model = configuration.model else { @@ -234,12 +193,11 @@ extension ChatGPTService { let requestBody = createRequestBody(prompt: prompt, model: model, stream: true) - let api = buildCompletionStreamAPI( - configuration.apiKey, - model, - url, - requestBody, - prompt + let api = chatCompletionsAPIBuilder.buildStreamAPI( + model: model, + endpoint: url, + apiKey: configuration.apiKey, + requestBody: requestBody ) #if DEBUG @@ -250,15 +208,13 @@ extension ChatGPTService { let task = Task { do { await memory.streamMessage( - id: proposedId, + id: proposedMessageId, role: .assistant, references: prompt.references ) let chunks = try await api() for try await chunk in chunks { - if Task.isCancelled { - throw CancellationError() - } + try Task.checkCancellation() guard let delta = chunk.message else { continue } // The api will always return a function call with JSON object. @@ -278,23 +234,19 @@ extension ChatGPTService { } await memory.streamMessage( - id: proposedId, + id: proposedMessageId, role: delta.role?.asChatMessageRole, content: delta.content, toolCalls: toolCalls ) if let toolCalls { - for toolCall in toolCalls.values { - continuation.yield(.toolCall(toolCall)) - } + continuation.yield(.partialToolCalls(toolCalls)) } if let content = delta.content { - continuation.yield(.text(content)) + continuation.yield(.partialText(content)) } - - try await Task.sleep(nanoseconds: 3_000_000) } continuation.finish() @@ -304,6 +256,7 @@ extension ChatGPTService { continuation.finish(throwing: error) } catch { await memory.appendMessage(.init( + id: uuid().uuidString, role: .assistant, content: error.localizedDescription )) @@ -311,8 +264,6 @@ extension ChatGPTService { } } - runningTask = task - continuation.onTermination = { _ in task.cancel() } @@ -322,15 +273,27 @@ extension ChatGPTService { /// When a function call is detected, but arguments are not yet ready, we can call this /// to report the status. func prepareFunctionCall( - _ call: ChatMessage.ToolCall - ) async -> AsyncStream { + _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, + sourceMessageId: String + ) async -> AsyncStream { return .init { continuation in guard let function = functionProvider.function(named: call.function.name) else { continuation.finish() + return } let task = Task { + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id + ) await function.prepare { summary in - continuation.yield(.status(summary)) + continuation.yield(summary) + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + summary: summary + ) } continuation.finish() } @@ -343,32 +306,55 @@ extension ChatGPTService { /// Run a function call from the bot. @discardableResult - func runFunctionCall(_ call: ChatMessage.ToolCall) async -> AsyncStream { + func runFunctionCall( + _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, + sourceMessageId: String + ) async -> AsyncStream { #if DEBUG Debugger.didReceiveFunction(name: call.function.name, arguments: call.function.arguments) #endif return .init { continuation in - let task = Task { guard let function = functionProvider.function(named: call.function.name) else { - let response = await fallbackFunctionCall(call) + let response = await fallbackFunctionCall( + call, + memory: memory, + sourceMessageId: sourceMessageId + ) continuation.yield(.output(response)) continuation.finish() return } + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id + ) + do { // Run the function let result = try await function .call(argumentsJsonString: call.function.arguments) { summary in continuation.yield(.status(summary)) + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + summary: summary + ) } #if DEBUG Debugger.didReceiveFunctionResult(result: result.botReadableContent) #endif + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: result.botReadableContent + ) + continuation.yield(.output(result.botReadableContent)) continuation.finish() } catch { @@ -379,6 +365,13 @@ extension ChatGPTService { Debugger.didReceiveFunctionResult(result: content) #endif + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: content, + summary: content + ) + continuation.yield(.output(content)) continuation.finish() } @@ -392,41 +385,48 @@ extension ChatGPTService { /// Mock a function call result when the bot is calling a function that is not implemented. func fallbackFunctionCall( - _ call: ChatMessage.ToolCall + _ call: ChatMessage.ToolCall, + memory: ChatGPTMemory, + sourceMessageId: String ) async -> String { + let temporaryMemory = ConversationChatGPTMemory(systemPrompt: { + if call.function.name == "python" { + return """ + Act like a Python interpreter. + I will give you Python code and you will execute it. + Reply with output of the code and tell me it's an answer generated by LLM. + """ + } else { + return """ + You are a function simulator. Your name is \(call.function.name). + Act like a function. + I will send you the arguments. + Reply with output of the function and tell me it's an answer generated by LLM. + """ + } + }()) + let service = ChatGPTService( - configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( - temperature: 0 - )), + configuration: OverridingChatGPTConfiguration( + overriding: UserPreferenceChatGPTConfiguration( + chatModelKey: \.preferredChatModelIdForUtilities + ), + with: .init(temperature: 0) + ), functionProvider: NoChatGPTFunctionProvider() ) - let stream = await service.send(.init( - history: [ - .init(role: .system, content: { - if call.function.name == "python" { - return """ - Act like a Python interpreter. - I will give you Python code and you will execute it. - Reply with output of the code and tell me it's an answer generated by LLM. - """ - } else { - return """ - You are a function simulator. Your name is \(call.function.name). - Act like a function. - I will send you the arguments. - Reply with output of the function and tell me it's an answer generated by LLM. - """ - } - }()), - ], - message: .init(role: .user, content: """ - \(call.function.arguments) - """) - )) + let stream = service.send(temporaryMemory) do { - return try await stream.asText() + let result = try await stream.asText() + await memory.streamToolCallResponse( + id: sourceMessageId, + toolCallId: call.id, + content: result, + summary: "Finished running function." + ) + return result } catch { return error.localizedDescription } @@ -522,9 +522,3 @@ extension ChatGPTService { } } -extension ChatGPTService { - func changeBuildCompletionStreamAPI(_ builder: @escaping ChatCompletionsStreamAPIBuilder) { - buildCompletionStreamAPI = builder - } -} - diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 47fe151b..3ea6e2f2 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -598,4 +598,3 @@ func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { guard let remainingTokens else { return nil } return min(maxToken / 2, remainingTokens) } - diff --git a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift index afa11eda..1cf793b5 100644 --- a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift +++ b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift @@ -10,11 +10,7 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { func ref(_ text: String) -> ChatMessage.Reference { .init( title: "", - subTitle: "", content: text, - uri: "", - startLine: nil, - endLine: nil, kind: .text ) } diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift new file mode 100644 index 00000000..12cd811e --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift @@ -0,0 +1,580 @@ +import AIModel +import ChatBasic +import Dependencies +import Foundation +import XCTest + +@testable import OpenAIService + +class ChatGPTServiceTests: XCTestCase { + func test_send_memory_and_handles_responses_with_chunks() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_returns_tool_calls() async throws { + let api = ChunksChatCompletionsStreamAPI( + chunks: [ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "foo")), + .init(index: 1, id: "2", type: "function", function: .init(name: "bar")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"bar\": \"bye\"}") + ), + ]), + ] + ) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .toolCalls([ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: nil + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: nil + ), + ]), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: nil + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: nil + ), + ] + ), + ]) + } + + func test_send_memory_and_automatically_handles_multiple_tool_calls() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [[ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "foo")), + .init(index: 1, id: "2", type: "function", function: .init(name: "bar")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"bar\": \"bye\"}") + ), + ]), + ], + [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ], + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration().overriding { + $0.runFunctionsAutomatically = true + }, + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .status("start foo 1"), + .status("start foo 2"), + .status("start foo 3"), + .status("start bar 1"), + .status("start bar 2"), + .status("start bar 3"), + .status("foo hi"), + .status("bar bye"), + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "foo hi", summary: "foo hi") + ), + .init( + id: "2", + type: "function", + function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"), + response: .init(content: "Error: bar error", summary: "Error: bar error") + ), + ] + ), + .init( + id: "00000000-0000-0000-0000-000000000001", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_and_automatically_handles_unknown_tool_call() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [[ + .partialToolCalls([ + .init(index: 0, id: "1", type: "function", function: .init(name: "python")), + .init(index: 1, id: "2", type: "function", function: .init(name: "unknown")), + ]), + .partialToolCalls([ + .init( + index: 0, + id: "1", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + .init( + index: 1, + id: "2", + type: "function", + function: .init(arguments: "{\"foo\": \"hi\"}") + ), + ]), + ], + [ + .token("result a"), + ], + [ + .token("result b"), + ], + [ + .token("hello"), + .token(" "), + .token("world"), + .token("!"), + .finish(reason: "finished"), + ], + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration().overriding { + $0.runFunctionsAutomatically = true + }, + functionProvider: FunctionProvider() + ) + return service.send(memory) + } + + let response = try await stream.asArray() + XCTAssertEqual(response, [ + .partialText("hello"), + .partialText(" "), + .partialText("world"), + .partialText("!"), + ]) + + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: nil, + toolCalls: [ + .init( + id: "1", + type: "function", + function: .init(name: "python", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "result a", summary: "Finished running function.") + ), + .init( + id: "2", + type: "function", + function: .init(name: "unknown", arguments: "{\"foo\": \"hi\"}"), + response: .init(content: "result b", summary: "Finished running function.") + ), + ] + ), + .init( + id: "00000000-0000-0000-0000-000000000003", + role: .assistant, + content: "hello world!" + ), + ]) + } + + func test_send_memory_and_handles_error() async throws { + struct E: Error, LocalizedError { + var errorDescription: String? { "error happens" } + } + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .failure(E()) + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + var results = [ChatGPTResponse]() + let expectError = expectation(description: "error") + do { + for try await item in stream { + results.append(item) + } + } catch is E { + expectError.fulfill() + } catch { + XCTFail("Incorrect Error") + } + + await fulfillment(of: [expectError], timeout: 1) + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello " + ), + .init( + id: "00000000-0000-0000-0000-000000000001", + role: .assistant, + content: "error happens" + ), + ]) + } + + func test_send_memory_and_handles_cancellation() async throws { + let api = ChunksChatCompletionsStreamAPI(chunks: [ + .token("hello"), + .token(" "), + .failure(CancellationError()) + ]) + let builder = APIBuilder(api: api) + let memory = EmptyChatGPTMemory() + let stream = withDependencies { values in + values.uuid = .incrementing + values.date = .constant(Date()) + values.chatCompletionsAPIBuilder = builder + } operation: { + let service = ChatGPTService( + configuration: EmptyConfiguration(), + functionProvider: NoChatGPTFunctionProvider() + ) + return service.send(memory) + } + + var results = [ChatGPTResponse]() + let expectError = expectation(description: "error") + do { + for try await item in stream { + results.append(item) + } + } catch is CancellationError { + expectError.fulfill() + } catch { + XCTFail("Incorrect Error") + } + + await fulfillment(of: [expectError], timeout: 1) + let history = await memory.history + XCTAssertEqual(history, [ + .init( + id: "00000000-0000-0000-0000-000000000000", + role: .assistant, + content: "hello " + ), + ]) + } +} + +private struct APIBuilder: ChatCompletionsAPIBuilder { + let api: ChatCompletionsStreamAPI + + func buildStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsStreamAPI { + api + } + + func buildNonStreamAPI( + model: ChatModel, + endpoint: URL, + apiKey: String, + requestBody: ChatCompletionsRequestBody + ) -> any ChatCompletionsAPI { + fatalError() + } +} + +private struct EmptyConfiguration: ChatGPTConfiguration { + var model: AIModel.ChatModel? { .init(id: "", name: "", format: .openAI, info: .init()) } + var temperature: Double { 0 } + var stop: [String] { [] } + var maxTokens: Int { 99999 } + var minimumReplyTokens: Int { 99999 } + var runFunctionsAutomatically: Bool { false } + var shouldEndTextWindow: (String) -> Bool = { _ in true } +} + +private class ChunksChatCompletionsStreamAPI: ChatCompletionsStreamAPI { + private(set) var chunks: [[Result]] + init(chunks: [Result]) { + self.chunks = [chunks] + } + + init(chunks: [[Result]]) { + self.chunks = chunks + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + let chunks = self.chunks.removeFirst() + return .init { + for chunk in chunks { + switch chunk { + case let .success(chunk): + $0.yield(chunk) + case let .failure(error): + $0.finish(throwing: error) + return + } + } + $0.finish() + } + } +} + +private struct ThrowingChatCompletionsStreamAPI: ChatCompletionsStreamAPI { + let error: any Error + func callAsFunction() async throws + -> AsyncThrowingStream + { + throw error + } +} + +private extension Result { + static func token(_ string: String) -> Result { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init(role: .assistant, content: string)), + finishReason: nil + )) + } + + static func partialToolCalls(_ toolCalls: [ChatCompletionsStreamDataChunk.Delta.ToolCall]) + -> Result + { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init( + role: .assistant, + content: nil, + toolCalls: toolCalls + )), + finishReason: nil + )) + } + + static func finish(reason: String) -> Result { + .success(.init( + id: "1", + object: "object", + model: "model", + message: .some(.init(role: .assistant, content: nil)), + finishReason: reason + )) + } +} + +private struct FunctionProvider: ChatGPTFunctionProvider { + struct Foo: ChatGPTFunction { + struct Arguments: Codable { + var foo: String + } + + struct Result: ChatGPTFunctionResult { + var result: String + var botReadableContent: String { result } + } + + var name: String { "foo" } + + var description: String { "foo" } + + var argumentSchema: ChatBasic.JSONSchemaValue = .string("") + + func prepare(reportProgress: @escaping ReportProgress) async { + await reportProgress("start foo 1") + await reportProgress("start foo 2") + await reportProgress("start foo 3") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + await reportProgress("foo \(arguments.foo)") + return .init(result: "foo \(arguments.foo)") + } + } + + struct Bar: ChatGPTFunction { + struct Arguments: Codable { + var bar: String + } + + struct Result: ChatGPTFunctionResult { + var result: String + var botReadableContent: String { result } + } + + var name: String { "bar" } + + var description: String { "bar" } + + var argumentSchema: ChatBasic.JSONSchemaValue = .string("") + + func prepare(reportProgress: @escaping ReportProgress) async { + await reportProgress("start bar 1") + await reportProgress("start bar 2") + await reportProgress("start bar 3") + } + + func call( + arguments: Arguments, + reportProgress: @escaping ReportProgress + ) async throws -> Result { + await reportProgress("bar \(arguments.bar)") + struct E: Error, LocalizedError { + var errorDescription: String? { "bar error" } + } + throw E() + } + } + + var functions: [any ChatGPTFunction] = [Foo(), Bar()] + + var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } +} + diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index dafaa527..69017883 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -16,10 +16,10 @@ final class ChatGPTStreamTests: XCTestCase { functionProvider: functionProvider ) var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - return MockCompletionStreamAPI_Message() - } +// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in +// requestBody = _requestBody +// return MockCompletionStreamAPI_Message() +// } try await withDependencies { values in values.uuid = .incrementing @@ -77,13 +77,13 @@ final class ChatGPTStreamTests: XCTestCase { functionProvider: functionProvider ) var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 2 { - return MockCompletionStreamAPI_Function() - } - return MockCompletionStreamAPI_Message() - } +// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in +// requestBody = _requestBody +// if _requestBody.messages.count <= 2 { +// return MockCompletionStreamAPI_Function() +// } +// return MockCompletionStreamAPI_Message() +// } try await withDependencies { values in values.uuid = .incrementing @@ -173,13 +173,13 @@ final class ChatGPTStreamTests: XCTestCase { ) var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 4 { - return MockCompletionStreamAPI_Function(count: 3) - } - return MockCompletionStreamAPI_Message() - } +// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in +// requestBody = _requestBody +// if _requestBody.messages.count <= 4 { +// return MockCompletionStreamAPI_Function(count: 3) +// } +// return MockCompletionStreamAPI_Message() +// } try await withDependencies { values in values.uuid = .incrementing @@ -310,13 +310,13 @@ final class ChatGPTStreamTests: XCTestCase { functionProvider: functionProvider ) var requestBody: ChatCompletionsRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in - requestBody = _requestBody - if _requestBody.messages.count <= 2 { - return MockCompletionStreamAPI_Function() - } - return MockCompletionStreamAPI_Message() - } +// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in +// requestBody = _requestBody +// if _requestBody.messages.count <= 2 { +// return MockCompletionStreamAPI_Function() +// } +// return MockCompletionStreamAPI_Message() +// } try await withDependencies { values in values.uuid = .incrementing diff --git a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift b/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift index e92d5079..7d8e89f8 100644 --- a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift @@ -3,164 +3,164 @@ import XCTest @testable import OpenAIService -class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { - func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() { - let prompt = ChatGPTPrompt(history: [ - .init(role: .system, content: "SystemPrompt"), - .init(role: .user, content: "A"), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]).googleAICompatible - - let expected = ChatGPTPrompt(history: [ - .init(role: .user, content: """ - System Prompt: - SystemPrompt - """), - .init(role: .assistant, content: "Got it. Let's start our conversation."), - .init(role: .user, content: "A"), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) - } - - func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() { - let prompt = ChatGPTPrompt(history: [ - .init(role: .system, content: "SystemPrompt"), - .init(role: .user, content: "A"), - .init(role: .user, content: "B"), - .init(role: .user, content: "C"), - .init(role: .assistant, content: "D"), - .init(role: .assistant, content: "E"), - .init(role: .assistant, content: "F"), - .init(role: .user, content: "World"), - ]).googleAICompatible - - let expected = ChatGPTPrompt(history: [ - .init(role: .user, content: """ - System Prompt: - SystemPrompt - """), - .init(role: .assistant, content: "Got it. Let's start our conversation."), - .init(role: .user, content: """ - A - - ====== - - B - - ====== - - C - """), - .init(role: .assistant, content: """ - D - - ====== - - E - - ====== - - F - """), - .init(role: .user, content: "World"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) - } - - func test_non_top_system_prompt_should_merge_as_user_prompt() { - let prompt = ChatGPTPrompt(history: [ - .init(role: .user, content: "A"), - .init(role: .system, content: "SystemPrompt"), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]).googleAICompatible - - let expected = ChatGPTPrompt(history: [ - .init(role: .user, content: """ - A - - ====== - - System Prompt: - SystemPrompt - """), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) - } - - func test_function_call_should_convert_assistant_and_user_message_with_text_content() { - let prompt = ChatGPTPrompt(history: [ - .init(role: .user, content: "A"), - .init( - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }"), - response: .init(content: "42ms", summary: nil) - ), - ] - ), - .init(role: .assistant, content: "Merge me"), - .init(role: .user, content: "Merge me"), - .init(role: .user, content: "Merge me"), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]).googleAICompatible - - let expected = ChatGPTPrompt(history: [ - .init(role: .user, content: "A"), - .init(role: .assistant, content: """ - Call function: ping - Arguments: { "ip": "127.0.0.1" } - Result: 42ms - - ====== - - Merge me - """), - .init(role: .user, content: """ - Merge me - - ====== - - Merge me - """), - .init(role: .assistant, content: "B"), - .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) - } - - func test_if_the_second_last_message_is_from_user_add_a_dummy() { - let prompt = ChatGPTPrompt(history: [ - .init(role: .user, content: "A"), - .init(role: .user, content: "Hello"), - ]).googleAICompatible - - let expected = ChatGPTPrompt(history: [ - .init(role: .user, content: "A"), - .init(role: .assistant, content: "OK"), - .init(role: .user, content: "Hello"), - ]) - - XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) - XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) - } -} - +//class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { +// func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() { +// let prompt = ChatGPTPrompt(history: [ +// .init(role: .system, content: "SystemPrompt"), +// .init(role: .user, content: "A"), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]).googleAICompatible +// +// let expected = ChatGPTPrompt(history: [ +// .init(role: .user, content: """ +// System Prompt: +// SystemPrompt +// """), +// .init(role: .assistant, content: "Got it. Let's start our conversation."), +// .init(role: .user, content: "A"), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]) +// +// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) +// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) +// } +// +// func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() { +// let prompt = ChatGPTPrompt(history: [ +// .init(role: .system, content: "SystemPrompt"), +// .init(role: .user, content: "A"), +// .init(role: .user, content: "B"), +// .init(role: .user, content: "C"), +// .init(role: .assistant, content: "D"), +// .init(role: .assistant, content: "E"), +// .init(role: .assistant, content: "F"), +// .init(role: .user, content: "World"), +// ]).googleAICompatible +// +// let expected = ChatGPTPrompt(history: [ +// .init(role: .user, content: """ +// System Prompt: +// SystemPrompt +// """), +// .init(role: .assistant, content: "Got it. Let's start our conversation."), +// .init(role: .user, content: """ +// A +// +// ====== +// +// B +// +// ====== +// +// C +// """), +// .init(role: .assistant, content: """ +// D +// +// ====== +// +// E +// +// ====== +// +// F +// """), +// .init(role: .user, content: "World"), +// ]) +// +// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) +// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) +// } +// +// func test_non_top_system_prompt_should_merge_as_user_prompt() { +// let prompt = ChatGPTPrompt(history: [ +// .init(role: .user, content: "A"), +// .init(role: .system, content: "SystemPrompt"), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]).googleAICompatible +// +// let expected = ChatGPTPrompt(history: [ +// .init(role: .user, content: """ +// A +// +// ====== +// +// System Prompt: +// SystemPrompt +// """), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]) +// +// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) +// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) +// } +// +// func test_function_call_should_convert_assistant_and_user_message_with_text_content() { +// let prompt = ChatGPTPrompt(history: [ +// .init(role: .user, content: "A"), +// .init( +// role: .assistant, +// content: nil, +// toolCalls: [ +// .init( +// id: "id", +// type: "function", +// function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }"), +// response: .init(content: "42ms", summary: nil) +// ), +// ] +// ), +// .init(role: .assistant, content: "Merge me"), +// .init(role: .user, content: "Merge me"), +// .init(role: .user, content: "Merge me"), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]).googleAICompatible +// +// let expected = ChatGPTPrompt(history: [ +// .init(role: .user, content: "A"), +// .init(role: .assistant, content: """ +// Call function: ping +// Arguments: { "ip": "127.0.0.1" } +// Result: 42ms +// +// ====== +// +// Merge me +// """), +// .init(role: .user, content: """ +// Merge me +// +// ====== +// +// Merge me +// """), +// .init(role: .assistant, content: "B"), +// .init(role: .user, content: "Hello"), +// ]) +// +// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) +// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) +// } +// +// func test_if_the_second_last_message_is_from_user_add_a_dummy() { +// let prompt = ChatGPTPrompt(history: [ +// .init(role: .user, content: "A"), +// .init(role: .user, content: "Hello"), +// ]).googleAICompatible +// +// let expected = ChatGPTPrompt(history: [ +// .init(role: .user, content: "A"), +// .init(role: .assistant, content: "OK"), +// .init(role: .user, content: "Hello"), +// ]) +// +// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) +// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) +// } +//} +// From 7586d76c1d404c1b74d89df0c4e3769a3a8c3015 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 18 Aug 2024 22:51:33 +0800 Subject: [PATCH 068/116] Reimplement LegacyChatGPTService with ChatGPTService --- .../OpenAIService/ChatGPTService.swift | 59 ++ .../OpenAIService/LegacyChatGPTService.swift | 529 +----------------- 2 files changed, 74 insertions(+), 514 deletions(-) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index c1a84e23..c35a25e2 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -6,6 +6,59 @@ import Foundation import IdentifiedCollections import Preferences +public enum ChatGPTServiceError: Error, LocalizedError { + case chatModelNotAvailable + case embeddingModelNotAvailable + case endpointIncorrect + case responseInvalid + case otherError(String) + + public var errorDescription: String? { + switch self { + case .chatModelNotAvailable: + return "Chat model is not available, please add a model in the settings." + case .embeddingModelNotAvailable: + return "Embedding model is not available, please add a model in the settings." + case .endpointIncorrect: + return "ChatGPT endpoint is incorrect" + case .responseInvalid: + return "Response is invalid" + case let .otherError(content): + return content + } + } +} + +public struct ChatGPTError: Error, Codable, LocalizedError { + public var error: ErrorContent + public init(error: ErrorContent) { + self.error = error + } + + public struct ErrorContent: Codable { + public var message: String + public var type: String? + public var param: String? + public var code: String? + + public init( + message: String, + type: String? = nil, + param: String? = nil, + code: String? = nil + ) { + self.message = message + self.type = type + self.param = param + self.code = code + } + } + + public var errorDescription: String? { + error.message + } +} + public enum ChatGPTResponse: Equatable { case status(String) case partialText(String) @@ -520,5 +573,11 @@ extension ChatGPTService { return requestBody } + + + func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { + guard let remainingTokens else { return nil } + return min(maxToken / 2, remainingTokens) + } } diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 3ea6e2f2..828e6193 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -14,59 +14,6 @@ public protocol LegacyChatGPTServiceType { func stopReceivingMessage() async } -public enum ChatGPTServiceError: Error, LocalizedError { - case chatModelNotAvailable - case embeddingModelNotAvailable - case endpointIncorrect - case responseInvalid - case otherError(String) - - public var errorDescription: String? { - switch self { - case .chatModelNotAvailable: - return "Chat model is not available, please add a model in the settings." - case .embeddingModelNotAvailable: - return "Embedding model is not available, please add a model in the settings." - case .endpointIncorrect: - return "ChatGPT endpoint is incorrect" - case .responseInvalid: - return "Response is invalid" - case let .otherError(content): - return content - } - } -} - -public struct ChatGPTError: Error, Codable, LocalizedError { - public var error: ErrorContent - public init(error: ErrorContent) { - self.error = error - } - - public struct ErrorContent: Codable { - public var message: String - public var type: String? - public var param: String? - public var code: String? - - public init( - message: String, - type: String? = nil, - param: String? = nil, - code: String? = nil - ) { - self.message = message - self.type = type - self.param = param - self.code = code - } - } - - public var errorDescription: String? { - error.message - } -} - @available(*, deprecated, message: "Use ChatGPTServiceType instead.") public class LegacyChatGPTService: LegacyChatGPTServiceType { public var memory: ChatGPTMemory @@ -111,72 +58,19 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { await memory.appendMessage(newMessage) } - return Debugger.$id.withValue(.init()) { - AsyncThrowingStream { continuation in - let task = Task(priority: .userInitiated) { - do { - var pendingToolCalls = [ChatMessage.ToolCall]() - var sourceMessageId = "" - var isInitialCall = true - loop: while !pendingToolCalls.isEmpty || isInitialCall { - try Task.checkCancellation() - isInitialCall = false - for toolCall in pendingToolCalls { - if !configuration.runFunctionsAutomatically { - break loop - } - await runFunctionCall( - toolCall, - sourceMessageId: sourceMessageId - ) - } - sourceMessageId = uuid() - .uuidString + String(date().timeIntervalSince1970) - let stream = try await sendMemory(proposedId: sourceMessageId) - - #if DEBUG - var reply = "" - #endif - - for try await content in stream { - try Task.checkCancellation() - switch content { - case let .text(text): - continuation.yield(text) - #if DEBUG - reply.append(text) - #endif - - case let .toolCall(toolCall): - await prepareFunctionCall( - toolCall, - sourceMessageId: sourceMessageId - ) - } - } - - pendingToolCalls = await memory.history - .last { $0.id == sourceMessageId }? - .toolCalls ?? [] + let service = ChatGPTService( + configuration: configuration, + functionProvider: functionProvider + ) - #if DEBUG - Debugger.didReceiveResponse(content: reply) - #endif - } + let responses = service.send(memory) - #if DEBUG - Debugger.didFinish() - #endif - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in - task.cancel() - } + return responses.compactMap { response in + switch response { + case let .partialText(token): return token + default: return nil } - } + }.eraseToThrowingStream() } /// Send a message and get the reply in return. @@ -193,408 +87,15 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { ) await memory.appendMessage(newMessage) } - return try await Debugger.$id.withValue(.init()) { - let message = try await sendMemoryAndWait() - var finalResult = message?.content - var toolCalls = message?.toolCalls - while let sourceMessageId = message?.id, let calls = toolCalls, !calls.isEmpty { - try Task.checkCancellation() - if !configuration.runFunctionsAutomatically { - break - } - toolCalls = nil - for call in calls { - await runFunctionCall(call, sourceMessageId: sourceMessageId) - } - guard let nextMessage = try await sendMemoryAndWait() else { break } - finalResult = nextMessage.content - toolCalls = nextMessage.toolCalls - } - - #if DEBUG - Debugger.didReceiveResponse(content: finalResult ?? "N/A") - Debugger.didFinish() - #endif - - return finalResult - } - } - - #warning("TODO: Move the cancellation up to the caller.") - public func stopReceivingMessage() { - runningTask?.cancel() - runningTask = nil - } -} - -// - MARK: Internal - -extension LegacyChatGPTService { - enum StreamContent { - case text(String) - case toolCall(ChatMessage.ToolCall) - } - - /// Send the memory as prompt to ChatGPT, with stream enabled. - func sendMemory(proposedId: String) async throws -> AsyncThrowingStream { - let prompt = await memory.generatePrompt() - - guard let model = configuration.model else { - throw ChatGPTServiceError.chatModelNotAvailable - } - guard let url = URL(string: configuration.endpoint) else { - throw ChatGPTServiceError.endpointIncorrect - } - - let requestBody = createRequestBody(prompt: prompt, model: model, stream: true) - - let api = chatCompletionsAPIBuilder.buildStreamAPI( - model: model, - endpoint: url, - apiKey: configuration.apiKey, - requestBody: requestBody - ) - - #if DEBUG - Debugger.didSendRequestBody(body: requestBody) - #endif - - return AsyncThrowingStream { continuation in - let task = Task { - do { - await memory.streamMessage( - id: proposedId, - role: .assistant, - references: prompt.references - ) - let chunks = try await api() - for try await chunk in chunks { - if Task.isCancelled { - throw CancellationError() - } - guard let delta = chunk.message else { continue } - - // The api will always return a function call with JSON object. - // The first round will contain the function name and an empty argument. - // e.g. {"name":"weather","arguments":""} - // The other rounds will contain part of the arguments. - let toolCalls = delta.toolCalls? - .reduce(into: [Int: ChatMessage.ToolCall]()) { - $0[$1.index ?? 0] = ChatMessage.ToolCall( - id: $1.id ?? "", - type: $1.type ?? "", - function: .init( - name: $1.function?.name ?? "", - arguments: $1.function?.arguments ?? "" - ) - ) - } - - await memory.streamMessage( - id: proposedId, - role: delta.role?.asChatMessageRole, - content: delta.content, - toolCalls: toolCalls - ) - - if let toolCalls { - for toolCall in toolCalls.values { - continuation.yield(.toolCall(toolCall)) - } - } - - if let content = delta.content { - continuation.yield(.text(content)) - } - - try await Task.sleep(nanoseconds: 3_000_000) - } - - continuation.finish() - } catch let error as CancellationError { - continuation.finish(throwing: error) - } catch let error as NSError where error.code == NSURLErrorCancelled { - continuation.finish(throwing: error) - } catch { - await memory.appendMessage(.init( - role: .assistant, - content: error.localizedDescription - )) - continuation.finish(throwing: error) - } - } - - runningTask = task - - continuation.onTermination = { _ in - task.cancel() - } - } - } - - /// Send the memory as prompt to ChatGPT, with stream disabled. - func sendMemoryAndWait() async throws -> ChatMessage? { - let proposedId = uuid().uuidString + String(date().timeIntervalSince1970) - let prompt = await memory.generatePrompt() - guard let model = configuration.model else { - throw ChatGPTServiceError.chatModelNotAvailable - } - guard let url = URL(string: configuration.endpoint) else { - throw ChatGPTServiceError.endpointIncorrect - } - - let requestBody = createRequestBody(prompt: prompt, model: model, stream: false) - - let api = chatCompletionsAPIBuilder.buildNonStreamAPI( - model: model, - endpoint: url, - apiKey: configuration.apiKey, - requestBody: requestBody + let service = ChatGPTService( + configuration: configuration, + functionProvider: functionProvider ) - #if DEBUG - Debugger.didSendRequestBody(body: requestBody) - #endif - - let response = try await api() - - let choice = response.message - let message = ChatMessage( - id: proposedId, - role: { - switch choice.role { - case .system: .system - case .user: .user - case .assistant: .assistant - case .tool: .user - } - }(), - content: choice.content, - name: choice.name, - toolCalls: choice.toolCalls?.map { - ChatMessage.ToolCall(id: $0.id, type: $0.type, function: .init( - name: $0.function.name, - arguments: $0.function.arguments ?? "" - )) - }, - references: prompt.references - ) - await memory.appendMessage(message) - return message - } - - /// When a function call is detected, but arguments are not yet ready, we can call this - /// to insert a message placeholder in memory. - func prepareFunctionCall(_ call: ChatMessage.ToolCall, sourceMessageId: String) async { - guard let function = functionProvider.function(named: call.function.name) else { return } - await memory.streamToolCallResponse(id: sourceMessageId, toolCallId: call.id) - await function.prepare { [weak self] summary in - await self?.memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - summary: summary - ) - } + return try await service.send(memory).asText() } - /// Run a function call from the bot, and insert the result in memory. - @discardableResult - func runFunctionCall( - _ call: ChatMessage.ToolCall, - sourceMessageId: String - ) async -> String { - #if DEBUG - Debugger.didReceiveFunction(name: call.function.name, arguments: call.function.arguments) - #endif - - guard let function = functionProvider.function(named: call.function.name) else { - return await fallbackFunctionCall(call, sourceMessageId: sourceMessageId) - } - - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id - ) - - do { - // Run the function - let result = try await function.call(argumentsJsonString: call.function.arguments) { - [weak self] summary in - await self?.memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - summary: summary - ) - } - - #if DEBUG - Debugger.didReceiveFunctionResult(result: result.botReadableContent) - #endif - - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: result.botReadableContent - ) - - return result.botReadableContent - } catch { - // For errors, use the error message as the result. - let content = "Error: \(error.localizedDescription)" - - #if DEBUG - Debugger.didReceiveFunctionResult(result: content) - #endif - - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: content - ) - return content - } - } - - /// Mock a function call result when the bot is calling a function that is not implemented. - func fallbackFunctionCall( - _ call: ChatMessage.ToolCall, - sourceMessageId: String - ) async -> String { - let memory = ConversationChatGPTMemory(systemPrompt: { - if call.function.name == "python" { - return """ - Act like a Python interpreter. - I will give you Python code and you will execute it. - Reply with output of the code and tell me it's an answer generated by LLM. - """ - } else { - return """ - You are a function simulator. Your name is \(call.function.name). - Act like a function. - I will send you the arguments. - Reply with output of the function and tell me it's an answer generated by LLM. - """ - } - }()) - - let service = LegacyChatGPTService( - memory: memory, - configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init( - temperature: 0 - )), - functionProvider: NoChatGPTFunctionProvider() - ) - - let content: String = await { - do { - return try await service.sendAndWait(content: """ - \(call.function.arguments) - """) ?? "No result." - } catch { - return "No result." - } - }() - await memory.streamToolCallResponse( - id: sourceMessageId, - toolCallId: call.id, - content: content, - summary: "Finished running function." - ) - return content - } - - func createRequestBody( - prompt: ChatGPTPrompt, - model: ChatModel, - stream: Bool - ) -> ChatCompletionsRequestBody { - let serviceSupportsFunctionCalling = switch model.format { - case .openAI, .openAICompatible, .azureOpenAI: - model.info.supportsFunctionCalling - case .ollama, .googleAI, .claude: - false - } - - let messages = prompt.history.flatMap { chatMessage in - var all = [ChatCompletionsRequestBody.Message]() - all.append(ChatCompletionsRequestBody.Message( - role: { - switch chatMessage.role { - case .system: .system - case .user: .user - case .assistant: .assistant - } - }(), - content: chatMessage.content ?? "", - name: chatMessage.name, - toolCalls: { - if serviceSupportsFunctionCalling { - chatMessage.toolCalls?.map { - .init( - id: $0.id, - type: $0.type, - function: .init( - name: $0.function.name, - arguments: $0.function.arguments - ) - ) - } - } else { - nil - } - }() - )) - - for call in chatMessage.toolCalls ?? [] { - if serviceSupportsFunctionCalling { - all.append(ChatCompletionsRequestBody.Message( - role: .tool, - content: call.response.content, - toolCallId: call.id - )) - } else { - all.append(ChatCompletionsRequestBody.Message( - role: .user, - content: call.response.content - )) - } - } - - return all - } - - let remainingTokens = prompt.remainingTokenCount - - let requestBody = ChatCompletionsRequestBody( - model: model.info.modelName, - messages: messages, - temperature: configuration.temperature, - stream: stream, - stop: configuration.stop.isEmpty ? nil : configuration.stop, - maxTokens: maxTokenForReply( - maxToken: model.info.maxTokens, - remainingTokens: remainingTokens - ), - toolChoice: serviceSupportsFunctionCalling - ? functionProvider.functionCallStrategy - : nil, - tools: serviceSupportsFunctionCalling - ? functionProvider.functions.map { - .init(function: ChatGPTFunctionSchema( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - )) - } - : [] - ) - - return requestBody - } + public func stopReceivingMessage() {} } -func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? { - guard let remainingTokens else { return nil } - return min(maxToken / 2, remainingTokens) -} From 2e03594bfd2207ba7da99e5a9a12f33e00c6761d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 18 Aug 2024 23:12:26 +0800 Subject: [PATCH 069/116] Remove unnecessary tests --- .../ChatGPTServiceFieldTests.swift | 40 -- .../ChatGPTStreamTests.swift | 548 ------------------ .../GoogleAIChatCompletionsAPITests.swift | 210 +++++++ .../LimitMessagesTests.swift | 2 +- ...matPromptToBeGoogleAICompatibleTests.swift | 166 ------ 5 files changed, 211 insertions(+), 755 deletions(-) delete mode 100644 Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift delete mode 100644 Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift create mode 100644 Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift delete mode 100644 Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift deleted file mode 100644 index 5bc6902a..00000000 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -import XCTest -@testable import OpenAIService - -final class ChatGPTServiceFieldTests: XCTestCase { - let skip = true - - func test_calling_the_api() async throws { - let service = LegacyChatGPTService() - - if skip { return } - - do { - let stream = try await service.send(content: "Hello") - for try await text in stream { - print(text) - } - } catch { - print("πŸ”΄", error.localizedDescription) - } - - XCTFail("πŸ”΄ Please reset skip to true.") - } - - func test_calling_the_api_with_function_calling() async throws { - let service = LegacyChatGPTService() - - if skip { return } - - do { - let stream = try await service.send(content: "Hello") - for try await text in stream { - print(text) - } - } catch { - print("πŸ”΄", error.localizedDescription) - } - - XCTFail("πŸ”΄ Please reset skip to true.") - } -} diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift deleted file mode 100644 index 69017883..00000000 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ /dev/null @@ -1,548 +0,0 @@ -import ChatBasic -import Dependencies -import XCTest -@testable import OpenAIService - -final class ChatGPTStreamTests: XCTestCase { - func test_sending_message() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = NoChatGPTFunctionProvider() - let service = LegacyChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? -// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in -// requestBody = _requestBody -// return MockCompletionStreamAPI_Message() -// } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init( - id: "s", - role: .system, - content: "system" - ), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, nil, "Function schema is not submitted") - } - } - - func test_handling_function_call() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = FunctionProvider() - let service = LegacyChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? -// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in -// requestBody = _requestBody -// if _requestBody.messages.count <= 2 { -// return MockCompletionStreamAPI_Function() -// } -// return MockCompletionStreamAPI_Message() -// } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "", - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - )] - ), - .init(role: .tool, content: "Function is called.", toolCallId: "id"), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, [ - EmptyFunction(), - ].map { - .init( - type: "function", - function: .init( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - ) - }, "Function schema is not submitted") - } - } - - func test_handling_multiple_function_call() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) - } - let functionProvider = FunctionProvider() - let service = LegacyChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? - -// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in -// requestBody = _requestBody -// if _requestBody.messages.count <= 4 { -// return MockCompletionStreamAPI_Function(count: 3) -// } -// return MockCompletionStreamAPI_Message() -// } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "", - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - .init( - id: "id2", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - .init( - id: "id3", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}") - ), - ] - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id" - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id2" - ), - .init( - role: .tool, - content: "Function is called.", - toolCallId: "id3" - ), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - .init( - id: "id2", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - .init( - id: "id3", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, [ - EmptyFunction(), - ].map { - .init( - type: "function", - function: .init( - name: $0.name, - description: $0.description, - parameters: $0.argumentSchema - ) - ) - }, "Function schema is not submitted") - } - } - - func test_function_calling_unsupported() async throws { - let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding { - $0.model = .init( - id: "id", - name: "name", - format: .openAI, - info: .init(supportsFunctionCalling: false) - ) - } - let functionProvider = FunctionProvider() - let service = LegacyChatGPTService( - memory: memory, - configuration: configuration, - functionProvider: functionProvider - ) - var requestBody: ChatCompletionsRequestBody? -// service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in -// requestBody = _requestBody -// if _requestBody.messages.count <= 2 { -// return MockCompletionStreamAPI_Function() -// } -// return MockCompletionStreamAPI_Message() -// } - - try await withDependencies { values in - values.uuid = .incrementing - values.date = .constant(.init(timeIntervalSince1970: 0)) - } operation: { - let stream = try await service.send(content: "Hello") - var all = [String]() - for try await text in stream { - all.append(text) - let history = await memory.history - XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0") - XCTAssertTrue( - history.last?.content?.hasPrefix(all.joined()) ?? false, - "History is not updated" - ) - } - - XCTAssertEqual(requestBody?.messages, [ - .init(role: .system, content: "system"), - .init(role: .user, content: "Hello"), - .init( - role: .assistant, content: "" - ), - .init(role: .user, content: "Function is called."), - ], "System prompt is not included") - - XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct") - - var history = await memory.history - for (i, _) in history.enumerated() { - history[i].tokensCount = nil - } - XCTAssertEqual(history, [ - .init(id: "s", role: .system, content: "system"), - .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"), - .init( - id: "00000000-0000-0000-0000-0000000000010.0", - role: .assistant, - content: nil, - toolCalls: [ - .init( - id: "id", - type: "function", - function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"), - response: .init(content: "Function is called.", summary: nil) - ), - ] - ), - .init( - id: "00000000-0000-0000-0000-0000000000030.0", - role: .assistant, - content: "hellomyfriends" - ), - ], "History is not updated") - - XCTAssertEqual(requestBody?.tools, nil, "Functions should be nil") - } - } -} - -extension ChatGPTStreamTests { - struct MockCompletionStreamAPI_Message: ChatCompletionsStreamAPI { - @Dependency(\.uuid) var uuid - func callAsFunction() async throws - -> AsyncThrowingStream - { - let id = uuid().uuidString - return AsyncThrowingStream { continuation in - let chunks: [ChatCompletionsStreamDataChunk] = [ - .init( - id: id, - object: "", - model: "", - message: .init(role: .assistant), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "hello"), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "my"), - finishReason: "" - ), - .init( - id: id, - object: "", - model: "", - message: .init(content: "friends"), - finishReason: "" - ), - ] - for chunk in chunks { - continuation.yield(chunk) - } - continuation.finish() - } - } - } - - struct MockCompletionStreamAPI_Function: ChatCompletionsStreamAPI { - @Dependency(\.uuid) var uuid - var count: Int = 1 - func callAsFunction() async throws - -> AsyncThrowingStream - { - let id = uuid().uuidString - return AsyncThrowingStream { continuation in - for i in 0.. String { - "Function is called." - } - } - - struct FunctionProvider: ChatGPTFunctionProvider { - var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil } - - var functions: [any ChatGPTFunction] { [EmptyFunction()] } - } -} - diff --git a/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift new file mode 100644 index 00000000..7044fa74 --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift @@ -0,0 +1,210 @@ +import Foundation +import GoogleGenerativeAI +import XCTest + +@testable import OpenAIService + +class GoogleAIChatCompletionsAPITests: XCTestCase { + let convert = GoogleAIChatCompletionsService.convertMessages + + func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() { + let prompt: [ChatCompletionsRequestBody.Message] = [ + .init(role: .system, content: "SystemPrompt"), + .init(role: .user, content: "A"), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let expected: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: """ + System Prompt: + SystemPrompt + """), + .init(role: .assistant, content: "Got it. Let's start our conversation."), + .init(role: .user, content: "A"), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) + } + + func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() { + let prompt: [ChatCompletionsRequestBody.Message] = [ + .init(role: .system, content: "SystemPrompt"), + .init(role: .user, content: "A"), + .init(role: .user, content: "B"), + .init(role: .user, content: "C"), + .init(role: .assistant, content: "D"), + .init(role: .assistant, content: "E"), + .init(role: .assistant, content: "F"), + .init(role: .user, content: "World"), + ] + + let expected: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: """ + System Prompt: + SystemPrompt + """), + .init(role: .assistant, content: "Got it. Let's start our conversation."), + .init(role: .user, content: """ + A + + ====== + + B + + ====== + + C + """), + .init(role: .assistant, content: """ + D + + ====== + + E + + ====== + + F + """), + .init(role: .user, content: "World"), + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) + } + + func test_non_top_system_prompt_should_merge_as_user_prompt() { + let prompt: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: "A"), + .init(role: .system, content: "SystemPrompt"), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let expected: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: """ + A + + ====== + + System Prompt: + SystemPrompt + """), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) + } + + func test_function_call_should_convert_assistant_and_user_message_with_text_content() { + let prompt: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: "A"), + .init( + role: .assistant, + content: "", + toolCalls: [ + .init( + id: "id", + type: "function", + function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }") + ), + ] + ), + .init(role: .tool, content: "42ms", toolCallId: "id"), + .init(role: .assistant, content: "Merge me"), + .init(role: .user, content: "Merge me"), + .init(role: .user, content: "Merge me"), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let expected: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: "A"), + .init(role: .assistant, content: """ + Function ID: id + Call function: ping + Arguments: { "ip": "127.0.0.1" } + """), + .init(role: .user, content: """ + Result of function ID: id + 42ms + """), + .init(role: .assistant, content: "Merge me"), + .init(role: .user, content: """ + Merge me + + ====== + + Merge me + """), + .init(role: .assistant, content: "B"), + .init(role: .user, content: "Hello"), + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) + } + + func test_if_the_second_last_message_is_from_user_add_a_dummy() { + let prompt: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: "A"), + .init(role: .user, content: "Hello"), + ] + + let expected: [ChatCompletionsRequestBody.Message] = [ + .init(role: .user, content: "A"), + .init(role: .assistant, content: "OK"), + .init(role: .user, content: "Hello"), + ] + + let converted = convert(prompt) + + XCTAssertEqual( + converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } }, + expected.map(\.content) + ) + XCTAssertEqual( + converted.map(\.role), + expected.map(\.role).map(ModelContent.convertRole(_:)) + ) + } +} + diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 3d47d6dc..46d355a3 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -26,7 +26,7 @@ final class AutoManagedChatGPTMemoryLimitTests: XCTestCase { ]) // XCTAssertEqual(remainingTokens, 10000 - 12 - 6) - let history = await memory.history +// let history = await memory.history // token count caching is removed // XCTAssertEqual(history.map(\.tokensCount), [ diff --git a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift b/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift deleted file mode 100644 index 7d8e89f8..00000000 --- a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Foundation -import XCTest - -@testable import OpenAIService - -//class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase { -// func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() { -// let prompt = ChatGPTPrompt(history: [ -// .init(role: .system, content: "SystemPrompt"), -// .init(role: .user, content: "A"), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]).googleAICompatible -// -// let expected = ChatGPTPrompt(history: [ -// .init(role: .user, content: """ -// System Prompt: -// SystemPrompt -// """), -// .init(role: .assistant, content: "Got it. Let's start our conversation."), -// .init(role: .user, content: "A"), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]) -// -// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) -// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) -// } -// -// func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() { -// let prompt = ChatGPTPrompt(history: [ -// .init(role: .system, content: "SystemPrompt"), -// .init(role: .user, content: "A"), -// .init(role: .user, content: "B"), -// .init(role: .user, content: "C"), -// .init(role: .assistant, content: "D"), -// .init(role: .assistant, content: "E"), -// .init(role: .assistant, content: "F"), -// .init(role: .user, content: "World"), -// ]).googleAICompatible -// -// let expected = ChatGPTPrompt(history: [ -// .init(role: .user, content: """ -// System Prompt: -// SystemPrompt -// """), -// .init(role: .assistant, content: "Got it. Let's start our conversation."), -// .init(role: .user, content: """ -// A -// -// ====== -// -// B -// -// ====== -// -// C -// """), -// .init(role: .assistant, content: """ -// D -// -// ====== -// -// E -// -// ====== -// -// F -// """), -// .init(role: .user, content: "World"), -// ]) -// -// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) -// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) -// } -// -// func test_non_top_system_prompt_should_merge_as_user_prompt() { -// let prompt = ChatGPTPrompt(history: [ -// .init(role: .user, content: "A"), -// .init(role: .system, content: "SystemPrompt"), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]).googleAICompatible -// -// let expected = ChatGPTPrompt(history: [ -// .init(role: .user, content: """ -// A -// -// ====== -// -// System Prompt: -// SystemPrompt -// """), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]) -// -// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) -// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) -// } -// -// func test_function_call_should_convert_assistant_and_user_message_with_text_content() { -// let prompt = ChatGPTPrompt(history: [ -// .init(role: .user, content: "A"), -// .init( -// role: .assistant, -// content: nil, -// toolCalls: [ -// .init( -// id: "id", -// type: "function", -// function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }"), -// response: .init(content: "42ms", summary: nil) -// ), -// ] -// ), -// .init(role: .assistant, content: "Merge me"), -// .init(role: .user, content: "Merge me"), -// .init(role: .user, content: "Merge me"), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]).googleAICompatible -// -// let expected = ChatGPTPrompt(history: [ -// .init(role: .user, content: "A"), -// .init(role: .assistant, content: """ -// Call function: ping -// Arguments: { "ip": "127.0.0.1" } -// Result: 42ms -// -// ====== -// -// Merge me -// """), -// .init(role: .user, content: """ -// Merge me -// -// ====== -// -// Merge me -// """), -// .init(role: .assistant, content: "B"), -// .init(role: .user, content: "Hello"), -// ]) -// -// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) -// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) -// } -// -// func test_if_the_second_last_message_is_from_user_add_a_dummy() { -// let prompt = ChatGPTPrompt(history: [ -// .init(role: .user, content: "A"), -// .init(role: .user, content: "Hello"), -// ]).googleAICompatible -// -// let expected = ChatGPTPrompt(history: [ -// .init(role: .user, content: "A"), -// .init(role: .assistant, content: "OK"), -// .init(role: .user, content: "Hello"), -// ]) -// -// XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content)) -// XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role)) -// } -//} -// From 7c849a155f19741a81b2e7b233bd6566dfe00ff8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 19 Aug 2024 00:33:37 +0800 Subject: [PATCH 070/116] Update --- .../APIs/OpenAIChatCompletionsService.swift | 3 + .../OpenAIService/ChatGPTService.swift | 3 + .../OpenAIService/LegacyChatGPTService.swift | 58 ++++++++++--------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 7a6672cb..d0b8a3ad 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -253,6 +253,9 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } guard let data = text.data(using: .utf8) else { throw ChatGPTServiceError.responseInvalid } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } let decoder = JSONDecoder() let error = try? decoder.decode(CompletionAPIError.self, from: data) throw error ?? ChatGPTServiceError.responseInvalid diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index c35a25e2..a35d8874 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -11,6 +11,7 @@ public enum ChatGPTServiceError: Error, LocalizedError { case embeddingModelNotAvailable case endpointIncorrect case responseInvalid + case unauthorized(String) case otherError(String) public var errorDescription: String? { @@ -23,6 +24,8 @@ public enum ChatGPTServiceError: Error, LocalizedError { return "ChatGPT endpoint is incorrect" case .responseInvalid: return "Response is invalid" + case let .unauthorized(reason): + return "Unauthorized: \(reason)" case let .otherError(content): return content } diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 828e6193..24beacfa 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -20,7 +20,7 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { public var configuration: ChatGPTConfiguration public var functionProvider: ChatGPTFunctionProvider - var runningTask: Task? + var runningTask: Task, Never>? public init( memory: ChatGPTMemory = AutoManagedChatGPTMemory( @@ -45,32 +45,36 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { content: String, summary: String? = nil ) async throws -> AsyncThrowingStream { - if !content.isEmpty || summary != nil { - let newMessage = ChatMessage( - id: uuid().uuidString, - role: .user, - content: content, - name: nil, - toolCalls: nil, - summary: summary, - references: [] + let task = Task { + if !content.isEmpty || summary != nil { + let newMessage = ChatMessage( + id: uuid().uuidString, + role: .user, + content: content, + name: nil, + toolCalls: nil, + summary: summary, + references: [] + ) + await memory.appendMessage(newMessage) + } + + let service = ChatGPTService( + configuration: configuration, + functionProvider: functionProvider ) - await memory.appendMessage(newMessage) + + let responses = service.send(memory) + + return responses.compactMap { response in + switch response { + case let .partialText(token): return token + default: return nil + } + }.eraseToThrowingStream() } - - let service = ChatGPTService( - configuration: configuration, - functionProvider: functionProvider - ) - - let responses = service.send(memory) - - return responses.compactMap { response in - switch response { - case let .partialText(token): return token - default: return nil - } - }.eraseToThrowingStream() + runningTask = task + return await task.value } /// Send a message and get the reply in return. @@ -96,6 +100,8 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { return try await service.send(memory).asText() } - public func stopReceivingMessage() {} + public func stopReceivingMessage() { + runningTask?.cancel() + } } From b1a216b900b4d8312a95954fb59b0ebe64ee2247 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 25 Aug 2024 15:35:50 +0800 Subject: [PATCH 071/116] Update version --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 10eba9dc..372d73b7 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.33.8 -APP_BUILD = 402 +APP_VERSION = 0.34.0 +APP_BUILD = 405 From 7703e1f732e52b38fb5d78a7a39e5d00daea42a0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 30 Aug 2024 21:39:05 +0800 Subject: [PATCH 072/116] Fix that previous/next suggestion will dismiss the suggestion panel --- .../CodeBlockSuggestionPanel.swift | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 8b03409d..9f1a041d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -74,10 +74,7 @@ struct CodeBlockSuggestionPanel: View { .monospacedDigit() Button(action: { - Task { - await commandHandler.presentNextSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } + Task { await commandHandler.presentNextSuggestion() } }) { Image(systemName: "chevron.right") }.buttonStyle(.plain) @@ -85,10 +82,7 @@ struct CodeBlockSuggestionPanel: View { Spacer() Button(action: { - Task { - await commandHandler.dismissSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } + Task { await commandHandler.dismissSuggestion() } }) { Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) }.buttonStyle(.plain) @@ -126,10 +120,7 @@ struct CodeBlockSuggestionPanel: View { WithPerceptionTracking { HStack { Button(action: { - Task { - await commandHandler.presentPreviousSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } + Task { await commandHandler.presentPreviousSuggestion() } }) { Image(systemName: "chevron.left") }.buttonStyle(.plain) @@ -140,10 +131,7 @@ struct CodeBlockSuggestionPanel: View { .monospacedDigit() Button(action: { - Task { - await commandHandler.presentNextSuggestion() - NSWorkspace.activatePreviousActiveXcode() - } + Task { await commandHandler.presentNextSuggestion() } }) { Image(systemName: "chevron.right") }.buttonStyle(.plain) From e534a452c87230b716e4a954ed48bac3d0eca6e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 2 Sep 2024 15:08:22 +0800 Subject: [PATCH 073/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 372d73b7..d433e626 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 405 +APP_BUILD = 406 From d0ac2aa72dc3a627d32198606934029053ab22be Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 2 Sep 2024 16:02:30 +0800 Subject: [PATCH 074/116] Remove PromptToCodeAcceptHandlerDependencyKey --- .../GraphicalUserInterfaceController.swift | 13 +------ .../FeatureReducers/PromptToCode.swift | 34 +++++++------------ 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 3428d318..966faa29 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -298,18 +298,7 @@ public final class GraphicalUserInterfaceController { dependencies.suggestionWidgetUserDefaultsObservers = .init() dependencies.chatTabPool = chatTabPool dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection - dependencies.promptToCodeAcceptHandler = { promptToCode in - Task { - let handler = PseudoCommandHandler() - await handler.acceptPromptToCode() - if !promptToCode.isContinuous { - NSWorkspace.activatePreviousActiveXcode() - } else { - NSWorkspace.activateThisApp() - } - } - } - + #if canImport(ChatTabPersistent) && canImport(ProChatTabs) dependencies.restoreChatTabInPool = { await chatTabPool.restore($0) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index 9ba5cad3..7741980e 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -6,23 +6,6 @@ import Foundation import PromptToCodeService import SuggestionBasic -public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { - public static let liveValue: (PromptToCode.State) -> Void = { _ in - assertionFailure("Please provide a handler") - } - - public static let previewValue: (PromptToCode.State) -> Void = { _ in - print("Accept Prompt to Code") - } -} - -public extension DependencyValues { - var promptToCodeAcceptHandler: (PromptToCode.State) -> Void { - get { self[PromptToCodeAcceptHandlerDependencyKey.self] } - set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } - } -} - @Reducer public struct PromptToCode { @ObservableState @@ -140,9 +123,11 @@ public struct PromptToCode { case appendNewLineToPromptButtonTapped } + @Dependency(\.commandHandler) var commandHandler @Dependency(\.promptToCodeService) var promptToCodeService - @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler - + @Dependency(\.activateThisApp) var activateThisApp + @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode + enum CancellationKey: Hashable { case modifyCode(State.ID) } @@ -258,8 +243,15 @@ public struct PromptToCode { return .none case .acceptButtonTapped: - promptToCodeAcceptHandler(state) - return .none + let isContinuous = state.isContinuous + return .run { _ in + await commandHandler.acceptPromptToCode() + if !isContinuous { + activatePreviousActiveXcode() + } else { + activateThisApp() + } + } case .copyCodeButtonTapped: NSPasteboard.general.clearContents() From 2f9e5c3058fcb970e7b5426a9b06f2a359b90424 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 4 Sep 2024 00:01:46 +0800 Subject: [PATCH 075/116] Add OpenAI-Organization and OpenAI-Project --- .../ChatModelManagement/ChatModelEdit.swift | 10 +++++++++- .../ChatModelEditView.swift | 18 +++++++++++++----- Tool/Sources/AIModel/ChatModel.swift | 5 ++++- .../APIs/OpenAIChatCompletionsService.swift | 8 ++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 95914577..0f5ec8f8 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -29,6 +29,8 @@ struct ChatModelEdit { var apiKeySelection: APIKeySelection.State = .init() var baseURLSelection: BaseURLSelection.State = .init() var enforceMessageOrder: Bool = false + var openAIOrganizationID: String = "" + var openAIProjectID: String = "" } enum Action: Equatable, BindableAction { @@ -197,6 +199,10 @@ extension ChatModel { } }(), modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + openAIInfo: .init( + organizationID: state.openAIOrganizationID, + projectID: state.openAIProjectID + ), ollamaInfo: .init(keepAlive: state.ollamaKeepAlive), googleGenerativeAIInfo: .init(apiVersion: state.apiVersion), openAICompatibleInfo: .init(enforceMessageOrder: state.enforceMessageOrder) @@ -219,7 +225,9 @@ extension ChatModel { apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName]) ), baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL), - enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder + enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder, + openAIOrganizationID: info.openAIInfo.organizationID, + openAIProjectID: info.openAIInfo.projectID ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 1eee9725..77605eac 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -243,6 +243,14 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) + + TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) { + Text("Organization ID") + } + + TextField(text: $store.openAIProjectID, prompt: Text("Optional")) { + Text("Project ID") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( @@ -308,7 +316,7 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) - + Toggle(isOn: $store.enforceMessageOrder) { Text("Enforce message order to be user/assistant alternated") } @@ -387,9 +395,9 @@ struct ChatModelEditView: View { BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) { Text("/v1/messages") } - + ApiKeyNamePicker(store: store) - + TextField("Model Name", text: $store.modelName) .overlay(alignment: .trailing) { Picker( @@ -411,9 +419,9 @@ struct ChatModelEditView: View { ) .frame(width: 20) } - + MaxTokensTextField(store: store) - + VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( " For more details, please visit [https://anthropic.com](https://anthropic.com)." diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 3695343a..e0f0ef5c 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -38,9 +38,12 @@ public struct ChatModel: Codable, Equatable, Identifiable { public struct OpenAIInfo: Codable, Equatable { @FallbackDecoding public var organizationID: String + @FallbackDecoding + public var projectID: String - public init(organizationID: String = "") { + public init(organizationID: String = "", projectID: String = "") { self.organizationID = organizationID + self.projectID = projectID } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index d0b8a3ad..618c7720 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -350,6 +350,14 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI forHTTPHeaderField: "OpenAI-Organization" ) } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .openAICompatible: request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") From b0b586c6094a60ab8ee5eb44f9b7ab449df6245f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 4 Sep 2024 15:16:48 +0800 Subject: [PATCH 076/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index d433e626..57eac2d5 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 406 +APP_BUILD = 407 From 128425b4d5fe436171b0862324653d29d96a3410 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 3 Sep 2024 23:49:50 +0800 Subject: [PATCH 077/116] Rename types --- .../GraphicalUserInterfaceController.swift | 6 +-- .../SuggestionWidget/ChatPanelWindow.swift | 2 +- .../SuggestionWidget/ChatWindowView.swift | 22 ++++---- ...ChatPanelFeature.swift => ChatPanel.swift} | 2 +- ...dgetFeature.swift => CircularWidget.swift} | 2 +- .../FeatureReducers/PromptToCodeGroup.swift | 22 ++++---- ...ptToCode.swift => PromptToCodePanel.swift} | 2 +- ...edPanelFeature.swift => SharedPanel.swift} | 4 +- ...nelFeature.swift => SuggestionPanel.swift} | 2 +- .../{WidgetFeature.swift => Widget.swift} | 22 ++++---- .../{PanelFeature.swift => WidgetPanel.swift} | 16 +++--- .../SuggestionWidget/SharedPanelView.swift | 14 ++--- ...ift => CodeBlockSuggestionPanelView.swift} | 10 ++-- ...{ErrorPanel.swift => ErrorPanelView.swift} | 2 +- ...anel.swift => PromptToCodePanelView.swift} | 54 +++++++++---------- .../SuggestionPanelView.swift | 6 +-- .../SuggestionWidgetController.swift | 4 +- .../Sources/SuggestionWidget/WidgetView.swift | 14 ++--- .../WidgetWindowsController.swift | 10 ++-- 19 files changed, 108 insertions(+), 108 deletions(-) rename Core/Sources/SuggestionWidget/FeatureReducers/{ChatPanelFeature.swift => ChatPanel.swift} (99%) rename Core/Sources/SuggestionWidget/FeatureReducers/{CircularWidgetFeature.swift => CircularWidget.swift} (98%) rename Core/Sources/SuggestionWidget/FeatureReducers/{PromptToCode.swift => PromptToCodePanel.swift} (99%) rename Core/Sources/SuggestionWidget/FeatureReducers/{SharedPanelFeature.swift => SharedPanel.swift} (92%) rename Core/Sources/SuggestionWidget/FeatureReducers/{SuggestionPanelFeature.swift => SuggestionPanel.swift} (94%) rename Core/Sources/SuggestionWidget/FeatureReducers/{WidgetFeature.swift => Widget.swift} (96%) rename Core/Sources/SuggestionWidget/FeatureReducers/{PanelFeature.swift => WidgetPanel.swift} (92%) rename Core/Sources/SuggestionWidget/SuggestionPanelContent/{CodeBlockSuggestionPanel.swift => CodeBlockSuggestionPanelView.swift} (98%) rename Core/Sources/SuggestionWidget/SuggestionPanelContent/{ErrorPanel.swift => ErrorPanelView.swift} (96%) rename Core/Sources/SuggestionWidget/SuggestionPanelContent/{PromptToCodePanel.swift => PromptToCodePanelView.swift} (94%) diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 966faa29..417531fe 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -22,9 +22,9 @@ import ChatTabPersistent struct GUI { @ObservableState struct State: Equatable { - var suggestionWidgetState = WidgetFeature.State() + var suggestionWidgetState = Widget.State() - var chatTabGroup: ChatPanelFeature.ChatTabGroup { + var chatTabGroup: ChatPanel.ChatTabGroup { get { suggestionWidgetState.chatPanelState.chatTabGroup } set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } @@ -64,7 +64,7 @@ struct GUI { case sendCustomCommandToActiveChat(CustomCommand) case toggleWidgetsHotkeyPressed - case suggestionWidget(WidgetFeature.Action) + case suggestionWidget(Widget.Action) static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index 35590f2b..462fc1f4 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -41,7 +41,7 @@ final class ChatPanelWindow: WidgetWindow { } init( - store: StoreOf, + store: StoreOf, chatTabPool: ChatTabPool, minimizeWindow: @escaping () -> Void ) { diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 06095086..ee655d0b 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -8,7 +8,7 @@ import SwiftUI private let r: Double = 8 struct ChatWindowView: View { - let store: StoreOf + let store: StoreOf let toggleVisibility: (Bool) -> Void var body: some View { @@ -38,7 +38,7 @@ struct ChatWindowView: View { } struct ChatTitleBar: View { - let store: StoreOf + let store: StoreOf @State var isHovering = false var body: some View { @@ -136,7 +136,7 @@ private extension View { } struct ChatTabBar: View { - let store: StoreOf + let store: StoreOf struct TabBarState: Equatable { var tabInfo: IdentifiedArray @@ -160,7 +160,7 @@ struct ChatTabBar: View { } struct Tabs: View { - let store: StoreOf + let store: StoreOf @State var draggingTabId: String? @Environment(\.chatTabPool) var chatTabPool @@ -226,7 +226,7 @@ struct ChatTabBar: View { } struct CreateButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { @@ -278,7 +278,7 @@ struct ChatTabBar: View { } struct ChatTabBarDropDelegate: DropDelegate { - let store: StoreOf + let store: StoreOf let tabs: IdentifiedArray let itemId: String @Binding var draggingTabId: String? @@ -302,7 +302,7 @@ struct ChatTabBarDropDelegate: DropDelegate { } struct ChatTabBarButton: View { - let store: StoreOf + let store: StoreOf let info: ChatTabInfo let content: () -> Content let icon: () -> Icon @@ -347,7 +347,7 @@ struct ChatTabBarButton: View { } struct ChatTabContainer: View { - let store: StoreOf + let store: StoreOf @Environment(\.chatTabPool) var chatTabPool var body: some View { @@ -406,8 +406,8 @@ struct ChatWindowView_Previews: PreviewProvider { "7": EmptyChatTab(id: "7"), ]) - static func createStore() -> StoreOf { - StoreOf( + static func createStore() -> StoreOf { + StoreOf( initialState: .init( chatTabGroup: .init( tabInfo: [ @@ -422,7 +422,7 @@ struct ChatWindowView_Previews: PreviewProvider { ), isPanelDisplayed: true ), - reducer: { ChatPanelFeature() } + reducer: { ChatPanel() } ) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift similarity index 99% rename from Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift index 2e02160a..ebc190d3 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift @@ -23,7 +23,7 @@ public struct ChatTabKind: Equatable { } @Reducer -public struct ChatPanelFeature { +public struct ChatPanel { public struct ChatTabGroup: Equatable { public var tabInfo: IdentifiedArray public var tabCollection: [ChatTabBuilderCollection] diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift similarity index 98% rename from Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift index 51b7d918..0a9e11c2 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift @@ -5,7 +5,7 @@ import SuggestionBasic import SwiftUI @Reducer -public struct CircularWidgetFeature { +public struct CircularWidget { public struct IsProcessingCounter: Equatable { var expirationDate: TimeInterval } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index b9617798..3b496905 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -8,10 +8,10 @@ import XcodeInspector public struct PromptToCodeGroup { @ObservableState public struct State: Equatable { - public var promptToCodes: IdentifiedArrayOf = [] - public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared .realtimeActiveDocumentURL - public var activePromptToCode: PromptToCode.State? { + public var activePromptToCode: PromptToCodePanel.State? { get { if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { return detached @@ -80,12 +80,12 @@ public struct PromptToCodeGroup { /// Activate the prompt to code if it exists or create it if it doesn't case activateOrCreatePromptToCode(PromptToCodeInitialState) case createPromptToCode(PromptToCodeInitialState) - case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) - case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) + case updatePromptToCodeRange(id: PromptToCodePanel.State.ID, range: CursorRange) + case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID) case updateActivePromptToCode(documentURL: URL) case discardExpiredPromptToCode(documentURLs: [URL]) - case promptToCode(PromptToCode.State.ID, PromptToCode.Action) - case activePromptToCode(PromptToCode.Action) + case promptToCode(PromptToCodePanel.State.ID, PromptToCodePanel.Action) + case activePromptToCode(PromptToCodePanel.Action) } @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory @@ -104,7 +104,7 @@ public struct PromptToCodeGroup { await send(.createPromptToCode(s)) } case let .createPromptToCode(s): - let newPromptToCode = PromptToCode.State( + let newPromptToCode = PromptToCodePanel.State( code: s.code, prompt: s.defaultPrompt, language: s.language, @@ -127,7 +127,7 @@ public struct PromptToCodeGroup { await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) } }.cancellable( - id: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id), + id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id), cancelInFlight: true ) @@ -159,11 +159,11 @@ public struct PromptToCodeGroup { } } .ifLet(\.activePromptToCode, action: \.activePromptToCode) { - PromptToCode() + PromptToCodePanel() .dependency(\.promptToCodeService, promptToCodeServiceFactory()) } .forEach(\.promptToCodes, action: /Action.promptToCode, element: { - PromptToCode() + PromptToCodePanel() .dependency(\.promptToCodeService, promptToCodeServiceFactory()) }) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift similarity index 99% rename from Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 7741980e..6832e4f9 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -7,7 +7,7 @@ import PromptToCodeService import SuggestionBasic @Reducer -public struct PromptToCode { +public struct PromptToCodePanel { @ObservableState public struct State: Equatable, Identifiable { public indirect enum HistoryNode: Equatable { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift similarity index 92% rename from Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift index 24636845..5b13b2f8 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift @@ -3,11 +3,11 @@ import Preferences import SwiftUI @Reducer -public struct SharedPanelFeature { +public struct SharedPanel { public struct Content: Equatable { public var promptToCodeGroup = PromptToCodeGroup.State() var suggestion: PresentingCodeSuggestion? - public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } + public var promptToCode: PromptToCodePanel.State? { promptToCodeGroup.activePromptToCode } var error: String? } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift similarity index 94% rename from Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift index 4521c54c..7baef1df 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift @@ -3,7 +3,7 @@ import Foundation import SwiftUI @Reducer -public struct SuggestionPanelFeature { +public struct SuggestionPanel { @ObservableState public struct State: Equatable { var content: PresentingCodeSuggestion? diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift similarity index 96% rename from Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift index 68ecc382..8111e746 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -10,7 +10,7 @@ import Toast import XcodeInspector @Reducer -public struct WidgetFeature { +public struct Widget { public struct WindowState: Equatable { var alphaValue: Double = 0 var frame: CGRect = .zero @@ -30,21 +30,21 @@ public struct WidgetFeature { // MARK: Panels - public var panelState = PanelFeature.State() + public var panelState = WidgetPanel.State() // MARK: ChatPanel - public var chatPanelState = ChatPanelFeature.State() + public var chatPanelState = ChatPanel.State() // MARK: CircularWidget public struct CircularWidgetState: Equatable { - var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]() + var isProcessingCounters = [CircularWidget.IsProcessingCounter]() var isProcessing: Bool = false } public var circularWidgetState = CircularWidgetState() - var _internalCircularWidgetState: CircularWidgetFeature.State { + var _internalCircularWidgetState: CircularWidget.State { get { .init( isProcessingCounters: circularWidgetState.isProcessingCounters, @@ -104,9 +104,9 @@ public struct WidgetFeature { case updateKeyWindow(WindowCanBecomeKey) case toastPanel(ToastPanel.Action) - case panel(PanelFeature.Action) - case chatPanel(ChatPanelFeature.Action) - case circularWidget(CircularWidgetFeature.Action) + case panel(WidgetPanel.Action) + case chatPanel(ChatPanel.Action) + case circularWidget(CircularWidget.Action) } var windowsController: WidgetWindowsController? { @@ -132,7 +132,7 @@ public struct WidgetFeature { } Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { - CircularWidgetFeature() + CircularWidget() } Reduce { state, action in @@ -181,11 +181,11 @@ public struct WidgetFeature { } Scope(state: \.panelState, action: \.panel) { - PanelFeature() + WidgetPanel() } Scope(state: \.chatPanelState, action: \.chatPanel) { - ChatPanelFeature() + ChatPanel() } Reduce { state, action in diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift similarity index 92% rename from Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift rename to Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift index a05a03cc..2c90331b 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -3,10 +3,10 @@ import ComposableArchitecture import Foundation @Reducer -public struct PanelFeature { +public struct WidgetPanel { @ObservableState public struct State: Equatable { - public var content: SharedPanelFeature.Content { + public var content: SharedPanel.Content { get { sharedPanelState.content } set { sharedPanelState.content = newValue @@ -16,11 +16,11 @@ public struct PanelFeature { // MARK: SharedPanel - var sharedPanelState = SharedPanelFeature.State() + var sharedPanelState = SharedPanel.State() // MARK: SuggestionPanel - var suggestionPanelState = SuggestionPanelFeature.State() + var suggestionPanelState = SuggestionPanel.State() } public enum Action: Equatable { @@ -33,8 +33,8 @@ public struct PanelFeature { case removeDisplayedContent case switchToAnotherEditorAndUpdateContent - case sharedPanel(SharedPanelFeature.Action) - case suggestionPanel(SuggestionPanelFeature.Action) + case sharedPanel(SharedPanel.Action) + case suggestionPanel(SuggestionPanel.Action) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @@ -44,11 +44,11 @@ public struct PanelFeature { public var body: some ReducerOf { Scope(state: \.suggestionPanelState, action: \.suggestionPanel) { - SuggestionPanelFeature() + SuggestionPanel() } Scope(state: \.sharedPanelState, action: \.sharedPanel) { - SharedPanelFeature() + SharedPanel() } Reduce { state, action in diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index d65995d4..f7f2f604 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -19,7 +19,7 @@ extension View { } struct SharedPanelView: View { - var store: StoreOf + var store: StoreOf struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -62,7 +62,7 @@ struct SharedPanelView: View { } struct DynamicContent: View { - let store: StoreOf + let store: StoreOf @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode @@ -82,7 +82,7 @@ struct SharedPanelView: View { @ViewBuilder func error(_ error: String) -> some View { - ErrorPanel(description: error) { + ErrorPanelView(description: error) { store.send( .errorMessageCloseButtonTapped, animation: .easeInOut(duration: 0.2) @@ -96,7 +96,7 @@ struct SharedPanelView: View { state: \.content.promptToCodeGroup.activePromptToCode, action: \.promptToCodeGroup.activePromptToCode ) { - PromptToCodePanel(store: store) + PromptToCodePanelView(store: store) } } @@ -106,7 +106,7 @@ struct SharedPanelView: View { case .nearbyTextCursor: EmptyView() case .floatingWidget: - CodeBlockSuggestionPanel(suggestion: suggestion) + CodeBlockSuggestionPanelView(suggestion: suggestion) } } } @@ -143,7 +143,7 @@ struct SharedPanelView_Error_Preview: PreviewProvider { colorScheme: .light, isPanelDisplayed: true ), - reducer: { SharedPanelFeature() } + reducer: { SharedPanel() } )) .frame(width: 450, height: 200) } @@ -171,7 +171,7 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { colorScheme: .dark, isPanelDisplayed: true ), - reducer: { SharedPanelFeature() } + reducer: { SharedPanel() } )) .frame(width: 450, height: 200) .background { diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift similarity index 98% rename from Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift rename to Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index 9f1a041d..4611afa7 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -38,7 +38,7 @@ public struct PresentingCodeSuggestion: Equatable { } } -struct CodeBlockSuggestionPanel: View { +struct CodeBlockSuggestionPanelView: View { let suggestion: PresentingCodeSuggestion @Environment(\.textCursorTracker) var textCursorTracker @Environment(\.colorScheme) var colorScheme @@ -320,7 +320,7 @@ struct CodeBlockSuggestionPanel: View { // MARK: - Previews #Preview("Code Block Suggestion Panel") { - CodeBlockSuggestionPanel(suggestion: PresentingCodeSuggestion( + CodeBlockSuggestionPanelView(suggestion: PresentingCodeSuggestion( code: """ LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { ForEach(0.. Void diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift similarity index 94% rename from Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift rename to Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 682d9c79..ebad2c9a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -4,8 +4,8 @@ import SharedUIComponents import SuggestionBasic import SwiftUI -struct PromptToCodePanel: View { - let store: StoreOf +struct PromptToCodePanelView: View { + let store: StoreOf var body: some View { WithPerceptionTracking { @@ -28,9 +28,9 @@ struct PromptToCodePanel: View { } } -extension PromptToCodePanel { +extension PromptToCodePanelView { struct TopBar: View { - let store: StoreOf + let store: StoreOf var body: some View { HStack { @@ -42,7 +42,7 @@ extension PromptToCodePanel { } struct SelectionRangeButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { Button(action: { @@ -101,7 +101,7 @@ extension PromptToCodePanel { } struct CopyCodeButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { if !store.code.isEmpty { @@ -115,7 +115,7 @@ extension PromptToCodePanel { } struct ActionBar: View { - let store: StoreOf + let store: StoreOf var body: some View { HStack { @@ -125,7 +125,7 @@ extension PromptToCodePanel { } struct StopRespondingButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { @@ -154,7 +154,7 @@ extension PromptToCodePanel { } struct ActionButtons: View { - @Perception.Bindable var store: StoreOf + @Perception.Bindable var store: StoreOf var body: some View { WithPerceptionTracking { @@ -205,7 +205,7 @@ extension PromptToCodePanel { } struct Content: View { - let store: StoreOf + let store: StoreOf @Environment(\.colorScheme) var colorScheme @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @@ -255,7 +255,7 @@ extension PromptToCodePanel { } struct ErrorMessage: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { @@ -280,7 +280,7 @@ extension PromptToCodePanel { } struct DescriptionContent: View { - let store: StoreOf + let store: StoreOf let codeForegroundColor: Color? var body: some View { @@ -301,7 +301,7 @@ extension PromptToCodePanel { } struct CodeContent: View { - let store: StoreOf + let store: StoreOf let codeForegroundColor: Color? @AppStorage(\.wrapCodeInPromptToCode) var wrapCode @@ -345,7 +345,7 @@ extension PromptToCodePanel { } struct CodeBlockInContent: View { - let store: StoreOf + let store: StoreOf let codeForegroundColor: Color? @Environment(\.colorScheme) var colorScheme @@ -377,8 +377,8 @@ extension PromptToCodePanel { } struct Toolbar: View { - let store: StoreOf - @FocusState var focusField: PromptToCode.State.FocusField? + let store: StoreOf + @FocusState var focusField: PromptToCodePanel.State.FocusField? struct RevertButtonState: Equatable { var isResponding: Bool @@ -420,7 +420,7 @@ extension PromptToCodePanel { } struct RevertButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { Button(action: { @@ -445,8 +445,8 @@ extension PromptToCodePanel { } struct InputField: View { - @Perception.Bindable var store: StoreOf - var focusField: FocusState.Binding + @Perception.Bindable var store: StoreOf + var focusField: FocusState.Binding var body: some View { WithPerceptionTracking { @@ -459,7 +459,7 @@ extension PromptToCodePanel { ) .opacity(store.isResponding ? 0.5 : 1) .disabled(store.isResponding) - .focused(focusField, equals: PromptToCode.State.FocusField.textField) + .focused(focusField, equals: PromptToCodePanel.State.FocusField.textField) .bind($store.focusedField, to: focusField) } .padding(8) @@ -468,7 +468,7 @@ extension PromptToCodePanel { } struct SendButton: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { Button(action: { @@ -489,7 +489,7 @@ extension PromptToCodePanel { // MARK: - Previews #Preview("Default") { - PromptToCodePanel(store: .init(initialState: .init( + PromptToCodePanelView(store: .init(initialState: .init( code: """ ForEach(0.. + let store: StoreOf struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -54,7 +54,7 @@ struct SuggestionPanelView: View { } struct Content: View { - let store: StoreOf + let store: StoreOf @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode var body: some View { @@ -63,7 +63,7 @@ struct SuggestionPanelView: View { ZStack(alignment: .topLeading) { switch suggestionPresentationMode { case .nearbyTextCursor: - CodeBlockSuggestionPanel(suggestion: content) + CodeBlockSuggestionPanelView(suggestion: content) case .floatingWidget: EmptyView() } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 3c708fbb..09a0ae7a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -11,7 +11,7 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { - let store: StoreOf + let store: StoreOf let chatTabPool: ChatTabPool let windowsController: WidgetWindowsController private var cancellable = Set() @@ -19,7 +19,7 @@ public final class SuggestionWidgetController: NSObject { public let dependency: SuggestionWidgetControllerDependency public init( - store: StoreOf, + store: StoreOf, chatTabPool: ChatTabPool, dependency: SuggestionWidgetControllerDependency ) { diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 42837b00..23494685 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -5,7 +5,7 @@ import SuggestionBasic import SwiftUI struct WidgetView: View { - let store: StoreOf + let store: StoreOf @State var isHovering: Bool = false var onOpenChatClicked: () -> Void = {} var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } @@ -41,7 +41,7 @@ struct WidgetView: View { } struct WidgetAnimatedCircle: View { - let store: StoreOf + let store: StoreOf @State var processingProgress: Double = 0 struct OverlayCircleState: Equatable { @@ -133,7 +133,7 @@ struct WidgetContextMenu: View { @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList @AppStorage(\.customCommands) var customCommands - let store: StoreOf + let store: StoreOf @Dependency(\.xcodeInspector) var xcodeInspector @@ -259,7 +259,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: { CircularWidgetFeature() } + reducer: { CircularWidget() } ), isHovering: false ) @@ -273,7 +273,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: { CircularWidgetFeature() } + reducer: { CircularWidget() } ), isHovering: true ) @@ -287,7 +287,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: { CircularWidgetFeature() } + reducer: { CircularWidget() } ), isHovering: false ) @@ -301,7 +301,7 @@ struct WidgetView_Preview: PreviewProvider { isChatPanelDetached: false, isChatOpen: false ), - reducer: { CircularWidgetFeature() } + reducer: { CircularWidget() } ), isHovering: false ) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 37c38435..bcae326d 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -17,7 +17,7 @@ actor WidgetWindowsController: NSObject { var xcodeInspector: XcodeInspector { .shared } let windows: WidgetWindows - let store: StoreOf + let store: StoreOf let chatTabPool: ChatTabPool var currentApplicationProcessIdentifier: pid_t? @@ -42,7 +42,7 @@ actor WidgetWindowsController: NSObject { updateWindowStateTask?.cancel() } - init(store: StoreOf, chatTabPool: ChatTabPool) { + init(store: StoreOf, chatTabPool: ChatTabPool) { self.store = store self.chatTabPool = chatTabPool windows = .init(store: store, chatTabPool: chatTabPool) @@ -50,7 +50,7 @@ actor WidgetWindowsController: NSObject { windows.controller = self } - @MainActor func send(_ action: WidgetFeature.Action) { + @MainActor func send(_ action: Widget.Action) { store.send(action) } @@ -655,7 +655,7 @@ extension WidgetWindowsController: NSWindowDelegate { // MARK: - Windows public final class WidgetWindows { - let store: StoreOf + let store: StoreOf let chatTabPool: ChatTabPool weak var controller: WidgetWindowsController? @@ -809,7 +809,7 @@ public final class WidgetWindows { }() init( - store: StoreOf, + store: StoreOf, chatTabPool: ChatTabPool ) { self.store = store From a27cd8202f5c61042d6f04d419603dc84126097e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Sep 2024 12:00:09 +0800 Subject: [PATCH 078/116] Bump Copilot.vim to 1.40.0 --- .../LanguageServer/GitHubCopilotInstallationManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 7778b0e5..ed11d4b2 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,12 +6,12 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "0668308e68b0ac28b332b204b469fbe04601536a" + let commitHash = "782461159655b259cff10ecff05efa761e3d4764" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.37.0" + static let latestSupportedVersion = "1.40.0" static let minimumSupportedVersion = "1.32.0" public init() {} From ffd9d4308fc2860ee4a19ed282872efd0d2ab508 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 5 Sep 2024 11:52:18 +0800 Subject: [PATCH 079/116] Update prompt to code panel --- Core/Package.swift | 3 + .../GraphicalUserInterfaceController.swift | 6 +- .../WindowBaseCommandHandler.swift | 116 ++-- .../FeatureReducers/PromptToCodeGroup.swift | 98 +--- .../FeatureReducers/PromptToCodePanel.swift | 301 ++++++----- .../FeatureReducers/SharedPanel.swift | 6 +- .../FeatureReducers/Widget.swift | 4 +- .../FeatureReducers/WidgetPanel.swift | 6 +- .../PromptToCodePanelView.swift | 505 +++++++++++------- Tool/Package.swift | 22 + .../PromptToCodeBasic/PromptToCodeAgent.swift | 91 ++++ .../PromptToCodeCustomization.swift | 156 ++++++ .../SharedUIComponents/AsyncCodeBlock.swift | 6 +- .../SharedUIComponents/CodeBlock.swift | 4 +- 14 files changed, 836 insertions(+), 488 deletions(-) create mode 100644 Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift create mode 100644 Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift diff --git a/Core/Package.swift b/Core/Package.swift index f18f6f37..fb5e8e03 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -86,6 +86,7 @@ let package = Package( .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "PromptToCode", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), @@ -152,6 +153,7 @@ let package = Package( .target( name: "PromptToCodeService", dependencies: [ + .product(name: "PromptToCode", package: "Tool"), .product(name: "FocusedCodeFinder", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), @@ -219,6 +221,7 @@ let package = Package( dependencies: [ "PromptToCodeService", "ChatGPTChatTab", + .product(name: "PromptToCode", package: "Tool"), .product(name: "Toast", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 417531fe..ff3fa4ab 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -21,10 +21,10 @@ import ChatTabPersistent @Reducer struct GUI { @ObservableState - struct State: Equatable { + struct State { var suggestionWidgetState = Widget.State() - var chatTabGroup: ChatPanel.ChatTabGroup { + var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup { get { suggestionWidgetState.chatPanelState.chatTabGroup } set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } } @@ -85,7 +85,7 @@ struct GUI { var body: some ReducerOf { CombineReducers { Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) { - WidgetFeature() + Widget() } Scope( diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index c267cdf7..9b67e798 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,12 +1,13 @@ import AppKit import ChatService +import ComposableArchitecture import Foundation import GitHubCopilotService import LanguageServerProtocol import Logger import OpenAIService -import SuggestionInjector import SuggestionBasic +import SuggestionInjector import SuggestionWidget import UserNotifications import Workspace @@ -175,61 +176,78 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { let injector = SuggestionInjector() var lines = editor.lines + var ranges = [CursorRange]() var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() let store = await Service.shared.guiController.store if let promptToCode = store.state.promptToCodeGroup.activePromptToCode { - if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { + if promptToCode.promptToCodeState.isAttachedToTarget, + promptToCode.promptToCodeState.source.documentURL != fileURL + { return nil } - let range = { - if promptToCode.isAttachedToSelectionRange, - let range = promptToCode.selectionRange - { - return range - } - return editor.selections.first.map { - CursorRange(start: $0.start, end: $0.end) - } ?? CursorRange( - start: editor.cursorPosition, - end: editor.cursorPosition + for snippet in promptToCode.promptToCodeState.snippets.sorted(by: { a, b in + a.attachedRange.start.line > b.attachedRange.start.line + }) { + let range = { + if promptToCode.promptToCodeState.isAttachedToTarget { + return snippet.attachedRange + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() + + ranges.append(range) + + let suggestion = CodeSuggestion( + id: UUID().uuidString, + text: snippet.modifiedCode, + position: range.start, + range: range ) - }() - let suggestion = CodeSuggestion( - id: UUID().uuidString, - text: promptToCode.code, - position: range.start, - range: range - ) + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: suggestion, + extraInfo: &extraInfo + ) - injector.acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursorPosition, - completion: suggestion, - extraInfo: &extraInfo - ) + _ = await Task { @MainActor [cursorPosition] in + await store.send( + .promptToCodeGroup(.updatePromptToCodeRange( + id: promptToCode.id, + snippetId: snippet.id, + range: .init(start: range.start, end: cursorPosition) + )) + ).finish() + }.value + } - _ = await Task { @MainActor [cursorPosition] in - store.send( - .promptToCodeGroup(.updatePromptToCodeRange( - id: promptToCode.id, - range: .init(start: range.start, end: cursorPosition) - )) - ) + _ = await MainActor.run { store.send( .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( id: promptToCode.id )) ) - }.result + } return .init( content: String(lines.joined(separator: "")), - newSelection: .init(start: range.start, end: cursorPosition), + newSelection: { + if ranges.isEmpty { + .init(start: cursorPosition, end: cursorPosition) + } else { + ranges.last + } + }(), modifications: extraInfo.modifications ) } @@ -405,22 +423,24 @@ extension WindowBaseCommandHandler { } _ = await Task { @MainActor in - // if there is already a prompt to code presenting, we should not present another one store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( - code: code, - selectionRange: selection, - language: codeLanguage, - identSize: filespace.codeMetadata.indentSize ?? 4, + promptToCodeState: Shared(.init( + source: .init( + language: codeLanguage, + documentURL: fileURL, + projectRootURL: workspace.projectRootURL, + content: editor.content, + lines: editor.lines + ), + originalCode: code, + attachedRange: selection, + instruction: newPrompt ?? "", + extraSystemPrompt: newExtraSystemPrompt ?? "" + )), + indentSize: filespace.codeMetadata.indentSize ?? 4, usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, - documentURL: fileURL, - projectRootURL: workspace.projectRootURL, - allCode: editor.content, - allLines: editor.lines, - isContinuous: isContinuous, commandName: name, - defaultPrompt: newPrompt ?? "", - extraSystemPrompt: newExtraSystemPrompt, - generateDescriptionRequirement: generateDescription + isContinuous: isContinuous )))) }.result } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 3b496905..82a856a9 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -7,13 +7,15 @@ import XcodeInspector @Reducer public struct PromptToCodeGroup { @ObservableState - public struct State: Equatable { + public struct State { public var promptToCodes: IdentifiedArrayOf = [] public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared .realtimeActiveDocumentURL public var activePromptToCode: PromptToCodePanel.State? { get { - if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { + if let detached = promptToCodes + .first(where: { !$0.promptToCodeState.isAttachedToTarget }) + { return detached } guard let id = activeDocumentURL else { return nil } @@ -27,60 +29,15 @@ public struct PromptToCodeGroup { } } - public struct PromptToCodeInitialState: Equatable { - public var code: String - public var selectionRange: CursorRange? - public var language: CodeLanguage - public var identSize: Int - public var usesTabsForIndentation: Bool - public var documentURL: URL - public var projectRootURL: URL - public var allCode: String - public var allLines: [String] - public var isContinuous: Bool - public var commandName: String? - public var defaultPrompt: String - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? - - public init( - code: String, - selectionRange: CursorRange?, - language: CodeLanguage, - identSize: Int, - usesTabsForIndentation: Bool, - documentURL: URL, - projectRootURL: URL, - allCode: String, - allLines: [String], - isContinuous: Bool, - commandName: String?, - defaultPrompt: String, - extraSystemPrompt: String?, - generateDescriptionRequirement: Bool? - ) { - self.code = code - self.selectionRange = selectionRange - self.language = language - self.identSize = identSize - self.usesTabsForIndentation = usesTabsForIndentation - self.documentURL = documentURL - self.projectRootURL = projectRootURL - self.allCode = allCode - self.allLines = allLines - self.isContinuous = isContinuous - self.commandName = commandName - self.defaultPrompt = defaultPrompt - self.extraSystemPrompt = extraSystemPrompt - self.generateDescriptionRequirement = generateDescriptionRequirement - } - } - - public enum Action: Equatable { + public enum Action { /// Activate the prompt to code if it exists or create it if it doesn't - case activateOrCreatePromptToCode(PromptToCodeInitialState) - case createPromptToCode(PromptToCodeInitialState) - case updatePromptToCodeRange(id: PromptToCodePanel.State.ID, range: CursorRange) + case activateOrCreatePromptToCode(PromptToCodePanel.State) + case createPromptToCode(PromptToCodePanel.State) + case updatePromptToCodeRange( + id: PromptToCodePanel.State.ID, + snippetId: UUID, + range: CursorRange + ) case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID) case updateActivePromptToCode(documentURL: URL) case discardExpiredPromptToCode(documentURLs: [URL]) @@ -103,27 +60,11 @@ public struct PromptToCodeGroup { return .run { send in await send(.createPromptToCode(s)) } - case let .createPromptToCode(s): - let newPromptToCode = PromptToCodePanel.State( - code: s.code, - prompt: s.defaultPrompt, - language: s.language, - indentSize: s.identSize, - usesTabsForIndentation: s.usesTabsForIndentation, - projectRootURL: s.projectRootURL, - documentURL: s.documentURL, - allCode: s.allCode, - allLines: s.allLines, - commandName: s.commandName, - isContinuous: s.isContinuous, - selectionRange: s.selectionRange, - extraSystemPrompt: s.extraSystemPrompt, - generateDescriptionRequirement: s.generateDescriptionRequirement - ) + case let .createPromptToCode(newPromptToCode): // insert at 0 so it has high priority then the other detached prompt to codes state.promptToCodes.insert(newPromptToCode, at: 0) return .run { send in - if !newPromptToCode.prompt.isEmpty { + if !newPromptToCode.promptToCodeState.instruction.isEmpty { await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) } }.cancellable( @@ -131,9 +72,10 @@ public struct PromptToCodeGroup { cancelInFlight: true ) - case let .updatePromptToCodeRange(id, range): - if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange { - state.promptToCodes[id: id]?.selectionRange = range + case let .updatePromptToCodeRange(id, snippetId, range): + if let p = state.promptToCodes[id: id], p.promptToCodeState.isAttachedToTarget { + state.promptToCodes[id: id]?.promptToCodeState.snippets[id: snippetId]? + .attachedRange = range } return .none @@ -153,7 +95,7 @@ public struct PromptToCodeGroup { case .promptToCode: return .none - + case .activePromptToCode: return .none } @@ -166,7 +108,7 @@ public struct PromptToCodeGroup { PromptToCodePanel() .dependency(\.promptToCodeService, promptToCodeServiceFactory()) }) - + Reduce { state, action in switch action { case let .promptToCode(id, .cancelButtonTapped): diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 6832e4f9..0a05bc9f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -3,110 +3,73 @@ import ComposableArchitecture import CustomAsyncAlgorithms import Dependencies import Foundation +import Preferences +import PromptToCodeBasic +import PromptToCodeCustomization import PromptToCodeService import SuggestionBasic @Reducer public struct PromptToCodePanel { @ObservableState - public struct State: Equatable, Identifiable { - public indirect enum HistoryNode: Equatable { - case empty - case node(code: String, description: String, previous: HistoryNode) - - mutating func enqueue(code: String, description: String) { - let current = self - self = .node(code: code, description: description, previous: current) - } - - mutating func pop() -> (code: String, description: String)? { - switch self { - case .empty: - return nil - case let .node(code, description, previous): - self = previous - return (code, description) - } - } - } - + public struct State: Identifiable { public enum FocusField: Equatable { case textField } - public var id: URL { documentURL } - public var history: HistoryNode - public var code: String - public var isResponding: Bool - public var description: String - public var error: String? - public var selectionRange: CursorRange? - public var language: CodeLanguage + @Shared public var promptToCodeState: PromptToCodeState + + public var id: URL { promptToCodeState.source.documentURL } + public var indentSize: Int public var usesTabsForIndentation: Bool - public var projectRootURL: URL - public var documentURL: URL - public var allCode: String - public var allLines: [String] - public var extraSystemPrompt: String? - public var generateDescriptionRequirement: Bool? public var commandName: String? - public var prompt: String public var isContinuous: Bool - public var isAttachedToSelectionRange: Bool public var focusedField: FocusField? = .textField - public var filename: String { documentURL.lastPathComponent } - public var canRevert: Bool { history != .empty } + public var filename: String { + promptToCodeState.source.documentURL.lastPathComponent + } + + public var canRevert: Bool { !promptToCodeState.history.isEmpty } + + public var generateDescriptionRequirement: Bool + + public var snippetPanels: IdentifiedArrayOf { + get { + IdentifiedArrayOf( + uniqueElements: promptToCodeState.snippets.reversed().map { + PromptToCodeSnippetPanel.State(snippet: $0) + } + ) + } + set { + promptToCodeState.snippets = IdentifiedArrayOf( + uniqueElements: newValue.map(\.snippet).reversed() + ) + } + } public init( - code: String, - prompt: String, - language: CodeLanguage, + promptToCodeState: Shared, indentSize: Int, usesTabsForIndentation: Bool, - projectRootURL: URL, - documentURL: URL, - allCode: String, - allLines: [String], commandName: String? = nil, - description: String = "", - isResponding: Bool = false, - isAttachedToSelectionRange: Bool = true, - error: String? = nil, - history: HistoryNode = .empty, isContinuous: Bool = false, - selectionRange: CursorRange? = nil, - extraSystemPrompt: String? = nil, - generateDescriptionRequirement: Bool? = nil + generateDescriptionRequirement: Bool = UserDefaults.shared + .value(for: \.promptToCodeGenerateDescription) ) { - self.history = history - self.code = code - self.prompt = prompt - self.isResponding = isResponding - self.description = description - self.error = error + _promptToCodeState = promptToCodeState self.isContinuous = isContinuous - self.selectionRange = selectionRange - self.language = language self.indentSize = indentSize self.usesTabsForIndentation = usesTabsForIndentation - self.projectRootURL = projectRootURL - self.documentURL = documentURL - self.allCode = allCode - self.allLines = allLines - self.extraSystemPrompt = extraSystemPrompt self.generateDescriptionRequirement = generateDescriptionRequirement - self.isAttachedToSelectionRange = isAttachedToSelectionRange self.commandName = commandName - - if selectionRange?.isEmpty ?? true { - self.isAttachedToSelectionRange = false - } + focusedField = .textField } } - public enum Action: Equatable, BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case focusOnTextField case selectionRangeToggleTapped @@ -114,20 +77,18 @@ public struct PromptToCodePanel { case revertButtonTapped case stopRespondingButtonTapped case modifyCodeFinished - case modifyCodeChunkReceived(code: String, description: String) - case modifyCodeFailed(error: String) case modifyCodeCancelled case cancelButtonTapped case acceptButtonTapped - case copyCodeButtonTapped case appendNewLineToPromptButtonTapped + case snippetPanel(IdentifiedActionOf) } @Dependency(\.commandHandler) var commandHandler @Dependency(\.promptToCodeService) var promptToCodeService @Dependency(\.activateThisApp) var activateThisApp @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode - + enum CancellationKey: Hashable { case modifyCode(State.ID) } @@ -140,107 +101,125 @@ public struct PromptToCodePanel { case .binding: return .none + case .snippetPanel: + return .none + case .focusOnTextField: state.focusedField = .textField return .none case .selectionRangeToggleTapped: - state.isAttachedToSelectionRange.toggle() + state.promptToCodeState.isAttachedToTarget.toggle() return .none case .modifyCodeButtonTapped: - guard !state.isResponding else { return .none } + guard !state.promptToCodeState.isGenerating else { return .none } let copiedState = state - state.history.enqueue(code: state.code, description: state.description) - state.isResponding = true - state.code = "" - state.description = "" - state.error = nil + state.promptToCodeState.isGenerating = true + state.promptToCodeState.pushHistory() + let snippets = state.promptToCodeState.snippets return .run { send in do { - let stream = try await promptToCodeService.modifyCode( - code: copiedState.code, - requirement: copiedState.prompt, - source: .init( - language: copiedState.language, - documentURL: copiedState.documentURL, - projectRootURL: copiedState.projectRootURL, - content: copiedState.allCode, - lines: copiedState.allLines, - range: copiedState.selectionRange ?? .outOfScope - ), - isDetached: !copiedState.isAttachedToSelectionRange, - extraSystemPrompt: copiedState.extraSystemPrompt, - generateDescriptionRequirement: copiedState - .generateDescriptionRequirement - ).timedDebounce(for: 0.2) - - for try await fragment in stream { - try Task.checkCancellation() - await send(.modifyCodeChunkReceived( - code: fragment.code, - description: fragment.description - )) + _ = try await withThrowingTaskGroup(of: Void.self) { group in + for snippet in snippets { + group.addTask { + let stream = try await promptToCodeService.modifyCode( + code: snippet.originalCode, + requirement: copiedState.promptToCodeState.instruction, + source: .init( + language: copiedState.promptToCodeState.source.language, + documentURL: copiedState.promptToCodeState.source + .documentURL, + projectRootURL: copiedState.promptToCodeState.source + .projectRootURL, + content: copiedState.promptToCodeState.source.content, + lines: copiedState.promptToCodeState.source.lines, + range: snippet.attachedRange + ), + isDetached: !copiedState.promptToCodeState + .isAttachedToTarget, + extraSystemPrompt: copiedState.promptToCodeState + .extraSystemPrompt, + generateDescriptionRequirement: copiedState + .generateDescriptionRequirement + ).timedDebounce(for: 0.2) + + do { + for try await fragment in stream { + try Task.checkCancellation() + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeChunkReceived( + code: fragment.code, + description: fragment.description + ) + ))) + } + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + if (error as NSError).code == NSURLErrorCancelled { + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFailed(error: "Cancelled") + ))) + return + } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFailed( + error: error + .localizedDescription + ) + ))) + } + } + } + + try await group.waitForAll() } - try Task.checkCancellation() + await send(.modifyCodeFinished) } catch is CancellationError { try Task.checkCancellation() await send(.modifyCodeCancelled) } catch { - try Task.checkCancellation() - if (error as NSError).code == NSURLErrorCancelled { - await send(.modifyCodeCancelled) - return - } - - await send(.modifyCodeFailed(error: error.localizedDescription)) + await send(.modifyCodeFinished) } }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) case .revertButtonTapped: - guard let (code, description) = state.history.pop() else { return .none } - state.code = code - state.description = description + state.promptToCodeState.popHistory() return .none case .stopRespondingButtonTapped: - state.isResponding = false + state.promptToCodeState.isGenerating = false promptToCodeService.stopResponding() return .cancel(id: CancellationKey.modifyCode(state.id)) - case let .modifyCodeChunkReceived(code, description): - state.code = code - state.description = description - return .none - case .modifyCodeFinished: - state.prompt = "" - state.isResponding = false - if state.code.isEmpty, state.description.isEmpty { + state.promptToCodeState.instruction = "" + state.promptToCodeState.isGenerating = false + + if state.promptToCodeState.snippets.allSatisfy({ snippet in + snippet.modifiedCode.isEmpty && snippet.description.isEmpty + }) { // if both code and description are empty, we treat it as failed return .run { send in await send(.revertButtonTapped) } } - return .none - case let .modifyCodeFailed(error): - state.error = error - state.isResponding = false - return .run { send in - await send(.revertButtonTapped) - } - case .modifyCodeCancelled: - state.isResponding = false + state.promptToCodeState.isGenerating = false return .none case .cancelButtonTapped: promptToCodeService.stopResponding() - return .none + return .cancel(id: CancellationKey.modifyCode(state.id)) case .acceptButtonTapped: let isContinuous = state.isContinuous @@ -253,13 +232,51 @@ public struct PromptToCodePanel { } } - case .copyCodeButtonTapped: - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(state.code, forType: .string) + case .appendNewLineToPromptButtonTapped: + state.promptToCodeState.instruction += "\n" return .none + } + } - case .appendNewLineToPromptButtonTapped: - state.prompt += "\n" + Reduce { _, _ in .none }.forEach(\.snippetPanels, action: \.snippetPanel) { + PromptToCodeSnippetPanel() + } + } +} + +@Reducer +public struct PromptToCodeSnippetPanel { + @ObservableState + public struct State: Identifiable { + public var id: UUID { snippet.id } + var snippet: PromptToCodeSnippet + } + + public enum Action { + case modifyCodeFinished + case modifyCodeChunkReceived(code: String, description: String) + case modifyCodeFailed(error: String) + case copyCodeButtonTapped + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .modifyCodeFinished: + return .none + + case let .modifyCodeChunkReceived(code, description): + state.snippet.modifiedCode = code + state.snippet.description = description + return .none + + case let .modifyCodeFailed(error): + state.snippet.error = error + return .none + + case .copyCodeButtonTapped: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(state.snippet.modifiedCode, forType: .string) return .none } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift index 5b13b2f8..b255949c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift @@ -4,7 +4,7 @@ import SwiftUI @Reducer public struct SharedPanel { - public struct Content: Equatable { + public struct Content { public var promptToCodeGroup = PromptToCodeGroup.State() var suggestion: PresentingCodeSuggestion? public var promptToCode: PromptToCodePanel.State? { promptToCodeGroup.activePromptToCode } @@ -12,7 +12,7 @@ public struct SharedPanel { } @ObservableState - public struct State: Equatable { + public struct State { var content: Content = .init() var colorScheme: ColorScheme = .light var alignTopToAnchor = false @@ -33,7 +33,7 @@ public struct SharedPanel { } } - public enum Action: Equatable { + public enum Action { case errorMessageCloseButtonTapped case promptToCodeGroup(PromptToCodeGroup.Action) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift index 8111e746..b8c20fa7 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift @@ -22,7 +22,7 @@ public struct Widget { } @ObservableState - public struct State: Equatable { + public struct State { var focusingDocumentURL: URL? public var colorScheme: ColorScheme = .light @@ -90,7 +90,7 @@ public struct Widget { case observeUserDefaults } - public enum Action: Equatable { + public enum Action { case startup case observeActiveApplicationChange case observeColorSchemeChange diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift index 2c90331b..10d409cf 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift @@ -5,7 +5,7 @@ import Foundation @Reducer public struct WidgetPanel { @ObservableState - public struct State: Equatable { + public struct State { public var content: SharedPanel.Content { get { sharedPanelState.content } set { @@ -23,11 +23,11 @@ public struct WidgetPanel { var suggestionPanelState = SuggestionPanel.State() } - public enum Action: Equatable { + public enum Action { case presentSuggestion case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool) case presentError(String) - case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) + case presentPromptToCode(PromptToCodePanel.State) case displayPanelContent case discardSuggestion case removeDisplayedContent diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index ebad2c9a..f07373b2 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1,5 +1,7 @@ import ComposableArchitecture import MarkdownUI +import PromptToCodeBasic +import PromptToCodeCustomization import SharedUIComponents import SuggestionBasic import SwiftUI @@ -9,18 +11,23 @@ struct PromptToCodePanelView: View { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { - TopBar(store: store) - - Content(store: store) - .overlay(alignment: .bottom) { - ActionBar(store: store) - .padding(.bottom, 8) - } + PromptToCodeCustomization.CustomizedUI( + state: store.$promptToCodeState, + isInputFieldFocused: .constant(true) + ) { _ in + VStack(spacing: 0) { + TopBar(store: store) + + Content(store: store) + .overlay(alignment: .bottom) { + ActionBar(store: store) + .padding(.bottom, 8) + } - Divider() + Divider() - Toolbar(store: store) + Toolbar(store: store) + } } .background(.ultraThickMaterial) .xcodeStyleFrame() @@ -33,12 +40,39 @@ extension PromptToCodePanelView { let store: StoreOf var body: some View { - HStack { - SelectionRangeButton(store: store) - Spacer() - CopyCodeButton(store: store) + WithPerceptionTracking { + VStack(spacing: 0) { + HStack { + SelectionRangeButton(store: store) + Spacer() + } + .padding(2) + + Divider() + + if let previousStep = store.promptToCodeState.history.last { + Button(action: { + store.send(.revertButtonTapped) + }, label: { + HStack { + Text(Image(systemName: "arrow.uturn.backward")) + Text(previousStep.instruction) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(.secondary) + Spacer() + } + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .disabled(store.promptToCodeState.isGenerating) + .padding(4) + + Divider() + } + } + .animation(.linear(duration: 0.1), value: store.promptToCodeState.history.count) } - .padding(2) } struct SelectionRangeButton: View { @@ -49,8 +83,7 @@ extension PromptToCodePanelView { store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1)) }) { let attachedToFilename = store.filename - let isAttached = store.isAttachedToSelectionRange - let selectionRange = store.selectionRange + let isAttached = store.promptToCodeState.isAttachedToTarget let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6) HStack(spacing: 4) { Image( @@ -74,9 +107,6 @@ extension PromptToCodePanelView { Text(attachedToFilename) .lineLimit(1) .truncationMode(.middle) - if let range = selectionRange { - Text(range.description) - } }.foregroundColor(.primary) } else { Text("current selection").foregroundColor(.secondary) @@ -99,19 +129,6 @@ extension PromptToCodePanelView { } } } - - struct CopyCodeButton: View { - let store: StoreOf - var body: some View { - WithPerceptionTracking { - if !store.code.isEmpty { - CopyButton { - store.send(.copyCodeButtonTapped) - } - } - } - } - } } struct ActionBar: View { @@ -129,7 +146,7 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - if store.isResponding { + if store.promptToCodeState.isGenerating { Button(action: { store.send(.stopRespondingButtonTapped) }) { @@ -158,14 +175,17 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - let isResponding = store.isResponding - let isCodeEmpty = store.code.isEmpty - let isDescriptionEmpty = store.description.isEmpty + let isResponding = store.promptToCodeState.isGenerating + let isCodeEmpty = store.promptToCodeState.snippets + .allSatisfy(\.modifiedCode.isEmpty) + let isDescriptionEmpty = store.promptToCodeState.snippets + .allSatisfy(\.description.isEmpty) var isRespondingButCodeIsReady: Bool { isResponding && !isCodeEmpty && !isDescriptionEmpty } + let isAttached = store.promptToCodeState.isAttachedToTarget if !isResponding || isRespondingButCodeIsReady { HStack { Toggle("Continuous Mode", isOn: $store.isContinuous) @@ -183,8 +203,13 @@ extension PromptToCodePanelView { Button(action: { store.send(.acceptButtonTapped) }) { - Text("Accept(⌘ + ⏎)") + if isAttached { + Text("Accept(⌘ + ⏎)") + } else { + Text("Replace(⌘ + ⏎)") + } } + .buttonStyle(CommandButtonStyle(color: .accentColor)) .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) } @@ -206,6 +231,7 @@ extension PromptToCodePanelView { struct Content: View { let store: StoreOf + @Environment(\.colorScheme) var colorScheme @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight @@ -243,10 +269,29 @@ extension PromptToCodePanelView { WithPerceptionTracking { ScrollView { VStack(spacing: 0) { - Spacer(minLength: 60) - ErrorMessage(store: store) - DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) - CodeContent(store: store, codeForegroundColor: codeForegroundColor) + Spacer(minLength: 56) + + VStack(spacing: 4) { + let language = store.promptToCodeState.source.language + let isAttached = store.promptToCodeState.isAttachedToTarget + ForEach(store.scope( + state: \.snippetPanels, + action: \.snippetPanel + )) { store in + Divider() + + SnippetPanelView( + store: store, + language: language, + codeForegroundColor: codeForegroundColor ?? .primary, + codeBackgroundColor: codeBackgroundColor, + isAttached: isAttached + ) + .padding(.horizontal, 4) + } + } + + Spacer(minLength: 16) } } .background(codeBackgroundColor) @@ -254,45 +299,98 @@ extension PromptToCodePanelView { } } + struct SnippetPanelView: View { + let store: StoreOf + let language: CodeLanguage + let codeForegroundColor: Color + let codeBackgroundColor: Color + let isAttached: Bool + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + ErrorMessage(store: store) + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent( + store: store, + language: language, + codeForegroundColor: codeForegroundColor + ) + SnippetTitleBar(store: store, isAttached: isAttached) + } + } + } + } + + struct SnippetTitleBar: View { + let store: StoreOf + let isAttached: Bool + var body: some View { + WithPerceptionTracking { + HStack { + if isAttached { + Text(String(describing: store.snippet.attachedRange)) + .foregroundStyle(.tertiary) + .font(.callout) + } + Spacer() + CopyCodeButton(store: store) + } + .padding(.leading, 4) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + + struct CopyCodeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if !store.snippet.modifiedCode.isEmpty { + CopyButton { + store.send(.copyCodeButtonTapped) + } + } + } + } + } + struct ErrorMessage: View { - let store: StoreOf + let store: StoreOf var body: some View { WithPerceptionTracking { - if let errorMessage = store.error, !errorMessage.isEmpty { - Text(errorMessage) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Color.red, - in: RoundedRectangle(cornerRadius: 4, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) - } - .scaleEffect(x: 1, y: -1, anchor: .center) + if let errorMessage = store.snippet.error, !errorMessage.isEmpty { + ( + Text(Image(systemName: "exclamationmark.triangle.fill")) + + Text(" ") + + Text(errorMessage) + ) + .multilineTextAlignment(.leading) + .foregroundColor(.red) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .scaleEffect(x: 1, y: -1, anchor: .center) } } } } struct DescriptionContent: View { - let store: StoreOf + let store: StoreOf let codeForegroundColor: Color? var body: some View { WithPerceptionTracking { - if !store.description.isEmpty { - Markdown(store.description) + if !store.snippet.description.isEmpty { + Markdown(store.snippet.description) .textSelection(.enabled) .markdownTheme(.gitHub.text { BackgroundColor(Color.clear) ForegroundColor(codeForegroundColor) }) - .padding() + .padding(.horizontal) + .padding(.vertical, 4) .frame(maxWidth: .infinity) .scaleEffect(x: 1, y: -1, anchor: .center) } @@ -301,43 +399,33 @@ extension PromptToCodePanelView { } struct CodeContent: View { - let store: StoreOf + let store: StoreOf + let language: CodeLanguage let codeForegroundColor: Color? @AppStorage(\.wrapCodeInPromptToCode) var wrapCode var body: some View { WithPerceptionTracking { - if store.code.isEmpty { - Text( - store.isResponding - ? "Thinking..." - : "Enter your requirement to generate code." + if wrapCode { + CodeBlockInContent( + store: store, + language: language, + codeForegroundColor: codeForegroundColor ) - .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) - .padding() - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1, anchor: .center) } else { - if wrapCode { + ScrollView(.horizontal) { CodeBlockInContent( store: store, + language: language, codeForegroundColor: codeForegroundColor ) - } else { - ScrollView(.horizontal) { - CodeBlockInContent( - store: store, - codeForegroundColor: codeForegroundColor - ) - } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 - } + } + .modify { + if #available(macOS 13.0, *) { + $0.scrollIndicators(.hidden) + } else { + $0 } } } @@ -345,7 +433,8 @@ extension PromptToCodePanelView { } struct CodeBlockInContent: View { - let store: StoreOf + let store: StoreOf + let language: CodeLanguage let codeForegroundColor: Color? @Environment(\.colorScheme) var colorScheme @@ -354,12 +443,12 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - let startLineIndex = store.selectionRange?.start.line ?? 0 - let firstLinePrecedingSpaceCount = store.selectionRange?.start - .character ?? 0 + let startLineIndex = store.snippet.attachedRange.start.line + let firstLinePrecedingSpaceCount = store.snippet.attachedRange.start + .character CodeBlock( - code: store.code, - language: store.language.rawValue, + code: store.snippet.modifiedCode, + language: language.rawValue, startLineIndex: startLineIndex, scenario: "promptToCode", colorScheme: colorScheme, @@ -369,6 +458,7 @@ extension PromptToCodePanelView { proposedForegroundColor: codeForegroundColor ) .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) } } @@ -380,15 +470,8 @@ extension PromptToCodePanelView { let store: StoreOf @FocusState var focusField: PromptToCodePanel.State.FocusField? - struct RevertButtonState: Equatable { - var isResponding: Bool - var canRevert: Bool - } - var body: some View { HStack { - RevertButton(store: store) - HStack(spacing: 0) { InputField(store: store, focusField: $focusField) SendButton(store: store) @@ -419,31 +502,6 @@ extension PromptToCodePanelView { .background(.ultraThickMaterial) } - struct RevertButton: View { - let store: StoreOf - var body: some View { - WithPerceptionTracking { - Button(action: { - store.send(.revertButtonTapped) - }) { - Group { - Image(systemName: "arrow.uturn.backward") - } - .padding(6) - .background { - Circle().fill(Color(nsColor: .controlBackgroundColor)) - } - .overlay { - Circle() - .stroke(Color(nsColor: .controlColor), lineWidth: 1) - } - } - .buttonStyle(.plain) - .disabled(store.isResponding || !store.canRevert) - } - } - } - struct InputField: View { @Perception.Bindable var store: StoreOf var focusField: FocusState.Binding @@ -451,14 +509,14 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { AutoresizingCustomTextEditor( - text: $store.prompt, + text: $store.promptToCodeState.instruction, font: .systemFont(ofSize: 14), - isEditable: !store.isResponding, + isEditable: !store.promptToCodeState.isGenerating, maxHeight: 400, onSubmit: { store.send(.modifyCodeButtonTapped) } ) - .opacity(store.isResponding ? 0.5 : 1) - .disabled(store.isResponding) + .opacity(store.promptToCodeState.isGenerating ? 0.5 : 1) + .disabled(store.promptToCodeState.isGenerating) .focused(focusField, equals: PromptToCodePanel.State.FocusField.textField) .bind($store.focusedField, to: focusField) } @@ -478,7 +536,7 @@ extension PromptToCodePanelView { .padding(8) } .buttonStyle(.plain) - .disabled(store.isResponding) + .disabled(store.promptToCodeState.isGenerating) .keyboardShortcut(KeyEquivalent.return, modifiers: []) } } @@ -488,92 +546,127 @@ extension PromptToCodePanelView { // MARK: - Previews -#Preview("Default") { +#Preview("Multiple Snippets") { PromptToCodePanelView(store: .init(initialState: .init( - code: """ - ForEach(0.. AsyncThrowingStream +} + +public struct PromptToCodeSnippet: Equatable, Identifiable { + public let id = UUID() + public var startLineIndex: Int + public var originalCode: String + public var modifiedCode: String + public var description: String + public var error: String? + public var attachedRange: CursorRange + + public init( + startLineIndex: Int, + originalCode: String, + modifiedCode: String, + description: String, + error: String?, + attachedRange: CursorRange + ) { + self.startLineIndex = startLineIndex + self.originalCode = originalCode + self.modifiedCode = modifiedCode + self.description = description + self.error = error + self.attachedRange = attachedRange + } +} + +public enum PromptToCodeAttachedTarget: Equatable { + case file(URL, projectURL: URL, code: String, lines: [String]) + case dynamic +} + +public struct PromptToCodeHistoryNode: Equatable { + public var snippets: IdentifiedArrayOf + public var instruction: String + + public init(snippets: IdentifiedArrayOf, instruction: String) { + self.snippets = snippets + self.instruction = instruction + } +} + diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift new file mode 100644 index 00000000..703398cd --- /dev/null +++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift @@ -0,0 +1,156 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import PromptToCodeBasic +import SuggestionBasic +import SwiftUI + +public enum PromptToCodeCustomization { + public static var CustomizedUI: any PromptToCodeCustomizedUI = NoPromptToCodeCustomizedUI() +} + +public struct PromptToCodeCustomizationContextWrapper: View { + @State var context: AnyObject + let content: (AnyObject) -> Content + + init(context: O, @ViewBuilder content: @escaping (O) -> Content) { + self.context = context + self.content = { context in + content(context as! O) + } + } + + public var body: some View { + content(context) + } +} + +public protocol PromptToCodeCustomizedUI { + typealias PromptToCodeCustomizedViews = ( + extraMenuItems: AnyView?, + extraButtons: AnyView?, + extraAcceptButtonVariants: AnyView?, + inputField: AnyView? + ) + + func callAsFunction( + state: Shared, + isInputFieldFocused: Binding, + @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V + ) -> PromptToCodeCustomizationContextWrapper +} + +struct NoPromptToCodeCustomizedUI: PromptToCodeCustomizedUI { + private class Context {} + + func callAsFunction( + state: Shared, + isInputFieldFocused: Binding, + @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V + ) -> PromptToCodeCustomizationContextWrapper { + PromptToCodeCustomizationContextWrapper(context: Context()) { _ in + view(( + extraMenuItems: nil, + extraButtons: nil, + extraAcceptButtonVariants: nil, + inputField: nil + )) + } + } +} + +public struct PromptToCodeState: Equatable { + public struct Source: Equatable { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var content: String + public var lines: [String] + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + content: String, + lines: [String] + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.content = content + self.lines = lines + } + } + + public var source: Source + public var history: [PromptToCodeHistoryNode] = [] + public var snippets: IdentifiedArrayOf = [] + public var isGenerating: Bool = false + public var instruction: String + public var extraSystemPrompt: String + public var isAttachedToTarget: Bool = true + + public init( + source: Source, + history: [PromptToCodeHistoryNode] = [], + snippets: IdentifiedArrayOf, + instruction: String, + extraSystemPrompt: String, + isAttachedToTarget: Bool + ) { + self.history = history + self.snippets = snippets + isGenerating = false + self.instruction = instruction + self.isAttachedToTarget = isAttachedToTarget + self.extraSystemPrompt = extraSystemPrompt + self.source = source + } + + public init( + source: Source, + originalCode: String, + attachedRange: CursorRange, + instruction: String, + extraSystemPrompt: String + ) { + self.init( + source: source, + snippets: [ + .init( + startLineIndex: 0, + originalCode: originalCode, + modifiedCode: originalCode, + description: "", + error: nil, + attachedRange: attachedRange + ), + ], + instruction: instruction, + extraSystemPrompt: extraSystemPrompt, + isAttachedToTarget: !attachedRange.isEmpty + ) + } + + public mutating func popHistory() { + if !history.isEmpty { + let last = history.removeLast() + snippets = last.snippets + instruction = last.instruction + } + } + + public mutating func pushHistory() { + history.append(.init(snippets: snippets, instruction: instruction)) + let oldSnippets = snippets + snippets = IdentifiedArrayOf() + for var snippet in oldSnippets { + snippet.originalCode = snippet.modifiedCode + snippet.modifiedCode = "" + snippet.description = "" + snippet.error = nil + snippets.append(snippet) + } + } +} + diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 485e15ed..e4738df0 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -55,8 +55,8 @@ public struct AsyncCodeBlock: View { // chat: hid public var body: some View { WithPerceptionTracking { + let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount VStack(spacing: 2) { - let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount ForEach(Array(storage.highlightedContent.enumerated()), id: \.0) { item in let (index, attributedString) = item HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -84,7 +84,9 @@ public struct AsyncCodeBlock: View { // chat: hid .foregroundColor(.white) .font(.init(font)) .padding(.leading, 4) - .padding([.trailing, .top, .bottom]) + .padding(.trailing) + .padding(.top, commonPrecedingSpaceCount > 0 ? 8 : 0) + .padding(.bottom, 4) .onAppear { storage.dimmedCharacterCount = dimmedCharacterCount storage.highlightStorage.highlight(debounce: false, for: self) diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 1208c14b..77a4b976 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -84,7 +84,9 @@ public struct CodeBlock: View { .foregroundColor(.white) .font(.init(font)) .padding(.leading, 4) - .padding([.trailing, .top, .bottom]) + .padding(.trailing) + .padding(.top, commonPrecedingSpaceCount > 0 ? 8 : 0) + .padding(.bottom, 4) } static func highlight( From 0ee5824811955ea8619ffa6c22f42d2e0a4334b6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 01:02:37 +0800 Subject: [PATCH 080/116] Support sending back multiple selections to editor extension --- .../PseudoCommandHandler.swift | 2 +- .../WindowBaseCommandHandler.swift | 8 +----- EditorExtension/Helpers.swift | 26 ++++++++++++------- Tool/Sources/XPCShared/Models.swift | 10 +++++-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 23ba7c67..3ad83b8a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -459,7 +459,7 @@ extension PseudoCommandHandler { // recover selection range - if let selection = result.newSelection { + if let selection = result.newSelections.first { var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content) if let value = AXValueCreate(.cfRange, &range) { AXUIElementSetAttributeValue( diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 9b67e798..e244120b 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -241,13 +241,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return .init( content: String(lines.joined(separator: "")), - newSelection: { - if ranges.isEmpty { - .init(start: cursorPosition, end: cursorPosition) - } else { - ranges.last - } - }(), + newSelections: ranges, modifications: extraInfo.modifications ) } diff --git a/EditorExtension/Helpers.swift b/EditorExtension/Helpers.swift index 8851c279..beb49c66 100644 --- a/EditorExtension/Helpers.swift +++ b/EditorExtension/Helpers.swift @@ -1,5 +1,5 @@ -import SuggestionBasic import Foundation +import SuggestionBasic import XcodeKit import XPCShared @@ -19,16 +19,21 @@ extension XCSourceEditorCommandInvocation { } func accept(_ updatedContent: UpdatedContent) { - if let newSelection = updatedContent.newSelection { + if !updatedContent.newSelections.isEmpty { mutateCompleteBuffer( modifications: updatedContent.modifications, restoringSelections: false ) buffer.selections.removeAllObjects() - buffer.selections.add(XCSourceTextRange( - start: .init(line: newSelection.start.line, column: newSelection.start.character), - end: .init(line: newSelection.end.line, column: newSelection.end.character) - )) + for newSelection in updatedContent.newSelections { + buffer.selections.add(XCSourceTextRange( + start: .init( + line: newSelection.start.line, + column: newSelection.start.character + ), + end: .init(line: newSelection.end.line, column: newSelection.end.character) + )) + } } else { mutateCompleteBuffer( modifications: updatedContent.modifications, @@ -47,17 +52,17 @@ extension EditorContent { uti: buffer.contentUTI, cursorPosition: ((buffer.selections.lastObject as? XCSourceTextRange)?.end).map { CursorPosition(line: $0.line, character: $0.column) - } ?? CursorPosition(line: 0, character: 0), + } ?? CursorPosition(line: 0, character: 0), cursorOffset: -1, selections: buffer.selections.map { let sl = ($0 as? XCSourceTextRange)?.start.line ?? 0 let sc = ($0 as? XCSourceTextRange)?.start.column ?? 0 let el = ($0 as? XCSourceTextRange)?.end.line ?? 0 let ec = ($0 as? XCSourceTextRange)?.end.column ?? 0 - + return Selection( - start: CursorPosition( line: sl, character: sc ), - end: CursorPosition( line: el, character: ec ) + start: CursorPosition(line: sl, character: sc), + end: CursorPosition(line: el, character: ec) ) }, tabSize: buffer.tabWidth, @@ -96,3 +101,4 @@ extension Task where Failure == Error { private struct TimeoutError: LocalizedError { var errorDescription: String? = "Task timed out before completion" } + diff --git a/Tool/Sources/XPCShared/Models.swift b/Tool/Sources/XPCShared/Models.swift index f83317cf..82118502 100644 --- a/Tool/Sources/XPCShared/Models.swift +++ b/Tool/Sources/XPCShared/Models.swift @@ -53,12 +53,18 @@ public struct EditorContent: Codable { public struct UpdatedContent: Codable { public init(content: String, newSelection: CursorRange? = nil, modifications: [Modification]) { self.content = content - self.newSelection = newSelection + self.newSelections = if let newSelection { [newSelection] } else { [] } + self.modifications = modifications + } + + public init(content: String, newSelections: [CursorRange], modifications: [Modification]) { + self.content = content + self.newSelections = newSelections self.modifications = modifications } public var content: String - public var newSelection: CursorRange? + public var newSelections: [CursorRange] public var modifications: [Modification] } From 7a4ce3d9c6611d9b184018799642bd89da390a07 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 15:18:08 +0800 Subject: [PATCH 081/116] Adjust prompt to code panel UI --- .../WindowBaseCommandHandler.swift | 44 +++-- .../FeatureReducers/PromptToCodePanel.swift | 14 +- .../PromptToCodePanelView.swift | 153 +++++++++++++----- .../WidgetWindowsController.swift | 3 +- .../SharedUIComponents/AsyncCodeBlock.swift | 2 +- .../SharedUIComponents/CodeBlock.swift | 2 +- .../ModifierFlagsMonitor.swift | 55 +++++++ 7 files changed, 203 insertions(+), 70 deletions(-) create mode 100644 Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index e244120b..109184e3 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -6,6 +6,7 @@ import GitHubCopilotService import LanguageServerProtocol import Logger import OpenAIService +import PromptToCodeBasic import SuggestionBasic import SuggestionInjector import SuggestionWidget @@ -364,11 +365,18 @@ extension WindowBaseCommandHandler { let codeLanguage = languageIdentifierFromFileURL(fileURL) - let (code, selection) = { - guard var selection = editor.selections.last, - selection.start != selection.end - else { return ("", .cursor(editor.cursorPosition)) } - + let snippets = editor.selections.map { selection in + guard selection.start != selection.end else { + return PromptToCodeSnippet( + startLineIndex: selection.start.line, + originalCode: "", + modifiedCode: "", + description: "", + error: "", + attachedRange: .init(start: selection.start, end: selection.end) + ) + } + var selection = selection let isMultipleLine = selection.start.line != selection.end.line let isSpaceOnlyBeforeStartPositionOnTheSameLine = { guard selection.start.line >= 0, selection.start.line < editor.lines.count else { @@ -391,14 +399,16 @@ extension WindowBaseCommandHandler { // indentation. selection.start = .init(line: selection.start.line, character: 0) } - return ( - editor.selectedCode(in: selection), - .init( - start: .init(line: selection.start.line, character: selection.start.character), - end: .init(line: selection.end.line, character: selection.end.character) - ) + let selectedCode = editor.selectedCode(in: selection) + return PromptToCodeSnippet( + startLineIndex: selection.start.line, + originalCode: selectedCode, + modifiedCode: selectedCode, + description: "", + error: "", + attachedRange: .init(start: selection.start, end: selection.end) ) - }() as (String, CursorRange) + } let store = await Service.shared.guiController.store @@ -416,7 +426,7 @@ extension WindowBaseCommandHandler { nil } - _ = await Task { @MainActor in + _ = await MainActor.run { store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( promptToCodeState: Shared(.init( source: .init( @@ -426,17 +436,17 @@ extension WindowBaseCommandHandler { content: editor.content, lines: editor.lines ), - originalCode: code, - attachedRange: selection, + snippets: IdentifiedArray(snippets), instruction: newPrompt ?? "", - extraSystemPrompt: newExtraSystemPrompt ?? "" + extraSystemPrompt: newExtraSystemPrompt ?? "", + isAttachedToTarget: false )), indentSize: filespace.codeMetadata.indentSize ?? 4, usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, commandName: name, isContinuous: isContinuous )))) - }.result + } } func executeSingleRoundDialog( diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 0a05bc9f..0007c797 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -80,6 +80,7 @@ public struct PromptToCodePanel { case modifyCodeCancelled case cancelButtonTapped case acceptButtonTapped + case acceptAndContinueButtonTapped case appendNewLineToPromptButtonTapped case snippetPanel(IdentifiedActionOf) } @@ -222,14 +223,15 @@ public struct PromptToCodePanel { return .cancel(id: CancellationKey.modifyCode(state.id)) case .acceptButtonTapped: - let isContinuous = state.isContinuous return .run { _ in await commandHandler.acceptPromptToCode() - if !isContinuous { - activatePreviousActiveXcode() - } else { - activateThisApp() - } + activatePreviousActiveXcode() + } + + case .acceptAndContinueButtonTapped: + return .run { _ in + await commandHandler.acceptPromptToCode() + activateThisApp() } case .appendNewLineToPromptButtonTapped: diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index f07373b2..2f96e09c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1,3 +1,4 @@ +import Cocoa import ComposableArchitecture import MarkdownUI import PromptToCodeBasic @@ -54,8 +55,8 @@ extension PromptToCodePanelView { Button(action: { store.send(.revertButtonTapped) }, label: { - HStack { - Text(Image(systemName: "arrow.uturn.backward")) + HStack(spacing: 2) { + Text(Image(systemName: "arrow.uturn.backward.circle.fill")) Text(previousStep.instruction) .lineLimit(1) .truncationMode(.tail) @@ -115,11 +116,11 @@ extension PromptToCodePanelView { .padding(2) .padding(.trailing, 4) .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke(color, lineWidth: 1) } .background { - RoundedRectangle(cornerRadius: 4, style: .continuous) + RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(color.opacity(0.2)) } .padding(2) @@ -172,6 +173,7 @@ extension PromptToCodePanelView { struct ActionButtons: View { @Perception.Bindable var store: StoreOf + @Environment(\.modifierFlags) var modifierFlags var body: some View { WithPerceptionTracking { @@ -188,8 +190,18 @@ extension PromptToCodePanelView { let isAttached = store.promptToCodeState.isAttachedToTarget if !isResponding || isRespondingButCodeIsReady { HStack { - Toggle("Continuous Mode", isOn: $store.isContinuous) + Menu { + Toggle( + "Always accept and continue", + isOn: $store.isContinuous.animation(.easeInOut(duration: 0.1)) + ) .toggleStyle(.checkbox) + } label: { + Image(systemName: "gearshape.fill") + .foregroundStyle(.secondary) + } + .frame(width: 16) + .buttonStyle(.plain) Button(action: { store.send(.cancelButtonTapped) @@ -200,16 +212,46 @@ extension PromptToCodePanelView { .keyboardShortcut("w", modifiers: [.command]) if !isCodeEmpty { + let defaultModeIsContinuous = store.isContinuous + Button(action: { - store.send(.acceptButtonTapped) + switch ( + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true): + store.send(.acceptButtonTapped) + case (false, true): + store.send(.acceptAndContinueButtonTapped) + case (true, false): + store.send(.acceptAndContinueButtonTapped) + case (false, false): + store.send(.acceptButtonTapped) + } }) { - if isAttached { + switch ( + isAttached, + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true, true): + Text("Accept(βŒ₯ + ⌘ + ⏎)") + case (true, false, true): + Text("Accept and Continue(⌘ + ⏎)") + case (true, true, false): + Text("Accept and Continue(βŒ₯ + ⌘ + ⏎)") + case (true, false, false): Text("Accept(⌘ + ⏎)") - } else { + case (false, true, true): + Text("Replace(βŒ₯ + ⌘ + ⏎)") + case (false, false, true): + Text("Replace and Continue(⌘ + ⏎)") + case (false, true, false): + Text("Replace and Continue(βŒ₯ + ⌘ + ⏎)") + case (false, false, false): Text("Replace(⌘ + ⏎)") } } - .buttonStyle(CommandButtonStyle(color: .accentColor)) .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) } @@ -223,6 +265,10 @@ extension PromptToCodePanelView { RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) } + .animation( + .easeInOut(duration: 0.1), + value: store.promptToCodeState.snippets + ) } } } @@ -271,27 +317,26 @@ extension PromptToCodePanelView { VStack(spacing: 0) { Spacer(minLength: 56) - VStack(spacing: 4) { + VStack(spacing: 0) { let language = store.promptToCodeState.source.language let isAttached = store.promptToCodeState.isAttachedToTarget ForEach(store.scope( state: \.snippetPanels, action: \.snippetPanel - )) { store in - Divider() + )) { snippetStore in + if snippetStore.id != store.promptToCodeState.snippets.last?.id { + Divider() + } SnippetPanelView( - store: store, + store: snippetStore, language: language, codeForegroundColor: codeForegroundColor ?? .primary, codeBackgroundColor: codeBackgroundColor, isAttached: isAttached ) - .padding(.horizontal, 4) } } - - Spacer(minLength: 16) } } .background(codeBackgroundColor) @@ -316,7 +361,12 @@ extension PromptToCodePanelView { language: language, codeForegroundColor: codeForegroundColor ) - SnippetTitleBar(store: store, isAttached: isAttached) + SnippetTitleBar( + store: store, + language: language, + codeForegroundColor: codeForegroundColor, + isAttached: isAttached + ) } } } @@ -324,19 +374,25 @@ extension PromptToCodePanelView { struct SnippetTitleBar: View { let store: StoreOf + let language: CodeLanguage + let codeForegroundColor: Color let isAttached: Bool var body: some View { WithPerceptionTracking { HStack { + Text(language.rawValue) + .foregroundStyle(codeForegroundColor) + .font(.callout.bold()) + .lineLimit(1) if isAttached { Text(String(describing: store.snippet.attachedRange)) - .foregroundStyle(.tertiary) + .foregroundStyle(codeForegroundColor.opacity(0.5)) .font(.callout) } Spacer() CopyCodeButton(store: store) } - .padding(.leading, 4) + .padding(.leading, 8) .scaleEffect(x: 1, y: -1, anchor: .center) } } @@ -407,27 +463,35 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - if wrapCode { - CodeBlockInContent( - store: store, - language: language, - codeForegroundColor: codeForegroundColor - ) - } else { - ScrollView(.horizontal) { + if !store.snippet.modifiedCode.isEmpty { + if wrapCode { CodeBlockInContent( store: store, language: language, codeForegroundColor: codeForegroundColor ) - } - .modify { - if #available(macOS 13.0, *) { - $0.scrollIndicators(.hidden) - } else { - $0 + } else { + ScrollView(.horizontal) { + CodeBlockInContent( + store: store, + language: language, + codeForegroundColor: codeForegroundColor + ) + } + .modify { + if #available(macOS 13.0, *) { + $0.scrollIndicators(.hidden) + } else { + $0 + } } } + } else { + Text("Thinking...") + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .scaleEffect(x: 1, y: -1, anchor: .center) } } } @@ -444,15 +508,12 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { let startLineIndex = store.snippet.attachedRange.start.line - let firstLinePrecedingSpaceCount = store.snippet.attachedRange.start - .character - CodeBlock( + AsyncCodeBlock( code: store.snippet.modifiedCode, + originalCode: store.snippet.originalCode, language: language.rawValue, startLineIndex: startLineIndex, scenario: "promptToCode", - colorScheme: colorScheme, - firstLinePrecedingSpaceCount: firstLinePrecedingSpaceCount, font: codeFont.value.nsFont, droppingLeadingSpaces: hideCommonPrecedingSpaces, proposedForegroundColor: codeForegroundColor @@ -588,13 +649,13 @@ extension PromptToCodePanelView { .init( startLineIndex: 13, originalCode: """ - struct Bar { - var foo: Int - } + struct Foo { + var foo: Int + } """, modifiedCode: """ struct Bar { - var foo: String + var bar: String } """, description: "Cool", @@ -613,7 +674,9 @@ extension PromptToCodePanelView { usesTabsForIndentation: false, commandName: "Generate Code" ), reducer: { PromptToCodePanel() })) - .frame(width: 450, height: 400) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) } #Preview("Detached With Long File Name") { @@ -668,6 +731,8 @@ extension PromptToCodePanelView { usesTabsForIndentation: false, commandName: "Generate Code" ), reducer: { PromptToCodePanel() })) - .frame(width: 450, height: 400) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index bcae326d..00a635ff 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -5,6 +5,7 @@ import Combine import ComposableArchitecture import Dependencies import Foundation +import SharedUIComponents import SwiftUI import XcodeInspector @@ -727,7 +728,7 @@ public final class WidgetWindows { state: \.sharedPanelState, action: \.sharedPanel ) - ) + ).modifierFlagsMonitor() ) it.setIsVisible(true) it.canBecomeKeyChecker = { [store] in diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index e4738df0..8351864a 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -85,7 +85,7 @@ public struct AsyncCodeBlock: View { // chat: hid .font(.init(font)) .padding(.leading, 4) .padding(.trailing) - .padding(.top, commonPrecedingSpaceCount > 0 ? 8 : 0) + .padding(.top, commonPrecedingSpaceCount > 0 ? 12 : 4) .padding(.bottom, 4) .onAppear { storage.dimmedCharacterCount = dimmedCharacterCount diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 77a4b976..403cb9a4 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -85,7 +85,7 @@ public struct CodeBlock: View { .font(.init(font)) .padding(.leading, 4) .padding(.trailing) - .padding(.top, commonPrecedingSpaceCount > 0 ? 8 : 0) + .padding(.top, commonPrecedingSpaceCount > 0 ? 12 : 4) .padding(.bottom, 4) } diff --git a/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift new file mode 100644 index 00000000..1ae82b7f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift @@ -0,0 +1,55 @@ +import Cocoa +import Foundation +import SwiftUI + +public extension View { + func modifierFlagsMonitor() -> some View { + ModifierFlagsMonitorWrapper { self } + } +} + +public extension EnvironmentValues { + var modifierFlags: NSEvent.ModifierFlags { + get { self[ModifierFlagsEnvironmentKey.self] } + set { self[ModifierFlagsEnvironmentKey.self] = newValue } + } +} + +final class ModifierFlagsMonitor { + private var monitor: Any? + + deinit { stop() } + + func start(binding: Binding) { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged]) { event in + binding.wrappedValue = event.modifierFlags + return event + } + } + + func stop() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } +} + +struct ModifierFlagsMonitorWrapper: View { + @ViewBuilder let content: () -> Content + @State private var modifierFlags: NSEvent.ModifierFlags = [] + @State private var eventMonitor = ModifierFlagsMonitor() + + var body: some View { + content() + .environment(\.modifierFlags, modifierFlags) + .onAppear { eventMonitor.start(binding: $modifierFlags) } + .onDisappear { eventMonitor.stop() } + } +} + +struct ModifierFlagsEnvironmentKey: EnvironmentKey { + static let defaultValue: NSEvent.ModifierFlags = [] +} + From 7c63fea39c16daa0a6dbd93402b216cd29aa6e5c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 15:22:23 +0800 Subject: [PATCH 082/116] Rename prompt to code to modification --- .../CustomCommandView.swift | 4 ++-- .../EditCustomCommandView.swift | 2 +- Core/Sources/HostApp/FeatureSettingsView.swift | 4 ++-- Core/Sources/HostApp/ServiceView.swift | 4 ++-- .../PseudoCommandHandler.swift | 2 +- .../AcceptPromptToCodeCommand.swift | 2 +- EditorExtension/PromptToCodeCommand.swift | 2 +- README.md | 18 +++++++++--------- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 13f37404..53f2d99c 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -149,7 +149,7 @@ struct CustomCommandView: View { case .customChat: Text("Custom Chat") case .promptToCode: - Text("Prompt to Code") + Text("Modification") case .singleRoundDialog: Text("Single Round Dialog") } @@ -201,7 +201,7 @@ struct CustomCommandView: View { "This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well." ) } - SubSection(title: Text("Prompt to Code")) { + SubSection(title: Text("Modification")) { Text( "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well." ) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index c2a0f5c0..1dade9bb 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -37,7 +37,7 @@ struct EditCustomCommandView: View { case .sendMessage: return "Send Message" case .promptToCode: - return "Prompt to Code" + return "Modification" case .customChat: return "Custom Chat" case .singleRoundDialog: diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index ed0f186b..4df4f10c 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -26,8 +26,8 @@ struct FeatureSettingsView: View { } .sidebarItem( tag: 2, - title: "Prompt to Code", - subtitle: "Write code with natural language", + title: "Modification", + subtitle: "Write or modify code with natural language", image: "paintbrush" ) diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift index 07f8af77..2fff4bcf 100644 --- a/Core/Sources/HostApp/ServiceView.swift +++ b/Core/Sources/HostApp/ServiceView.swift @@ -33,7 +33,7 @@ struct ServiceView: View { )).sidebarItem( tag: 2, title: "Chat Models", - subtitle: "Chat, Prompt to Code", + subtitle: "Chat, Modification", image: "globe" ) @@ -43,7 +43,7 @@ struct ServiceView: View { )).sidebarItem( tag: 3, title: "Embedding Models", - subtitle: "Chat, Prompt to Code", + subtitle: "Chat, Modification", image: "globe" ) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 3ad83b8a..4001bdd6 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -207,7 +207,7 @@ struct PseudoCommandHandler: CommandHandler { } do { try await XcodeInspector.shared.safe.latestActiveXcode? - .triggerCopilotCommand(name: "Accept Prompt to Code") + .triggerCopilotCommand(name: "Accept Modification") } catch { let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI let now = Date() diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift index 51bea4a4..7ac95c35 100644 --- a/EditorExtension/AcceptPromptToCodeCommand.swift +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -4,7 +4,7 @@ import SuggestionBasic import XcodeKit class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Accept Prompt to Code" } + var name: String { "Accept Modification" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift index 13e4f3be..e7086d57 100644 --- a/EditorExtension/PromptToCodeCommand.swift +++ b/EditorExtension/PromptToCodeCommand.swift @@ -4,7 +4,7 @@ import Foundation import XcodeKit class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Prompt to Code" } + var name: String { "Write or Modify Code" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/README.md b/README.md index 893e3fe6..720d0a92 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil ## Features -- Code Suggestions (powered by GitHub Copilot and Codeium). -- Chat (powered by OpenAI ChatGPT). -- Prompt to Code (powered by OpenAI ChatGPT). -- Custom Commands to extend Chat and Prompt to Code. +- Code Suggestions +- Chat +- Modification +- Custom Commands to extend Chat and Modification. ## Table of Contents @@ -297,7 +297,7 @@ This feature is recommended when you need to update a specific piece of code. So - Polishing and correcting grammar and spelling errors in the documentation. - Translating a localizable strings file. -#### Prompt to Code Scope +#### Modification Scope The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`. @@ -307,14 +307,14 @@ You can use shorthand to represent a scope, such as `@sense`, and enable multipl #### Commands -- Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code. -- Accept Prompt to Code: Accept the result of prompt to code. +- Write or Modify Code: Open a modification window, where you can use natural language to write or edit selected code. +- Accept Modification: Accept the result of modification. ### Custom Commands -You can create custom commands that run Chat and Prompt to Code with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands: +You can create custom commands that run Chat and Modification with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands: -- Prompt to Code: Run Prompt to Code with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. +- Modification: Run Modification with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field. - Send Message: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field. - Custom Chat: Open the chat window and immediately send a message, if provided. You can overwrite the entire system prompt through the system prompt field. - Single Round Dialog: Send a message to a temporary chat. Useful when you want to run a terminal command with `/run`. From d8aef9b55be7b42a67c195d32318d4185ea3558a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 23:51:14 +0800 Subject: [PATCH 083/116] Fix test location --- TestPlan.xctestplan | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index ad5ee381..8806c7f2 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -157,7 +157,7 @@ }, { "target" : { - "containerPath" : "container:", + "containerPath" : "container:Tool", "identifier" : "CodeDiffTests", "name" : "CodeDiffTests" } From d8d6a8d6619ec8651fbf4b022823ee6c653c4d35 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 23:51:41 +0800 Subject: [PATCH 084/116] Add scheme --- .../Copilot for Xcode Debug.xcscheme | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme new file mode 100644 index 00000000..70ab5d8d --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7e8338d94a1575a4aae856face93d9fadfe2dab8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 23:52:21 +0800 Subject: [PATCH 085/116] Update SuggestionInjector to support accepting multiple suggestions at a time --- .../SuggestionInjector.swift | 49 +++- .../AcceptSuggestionTests.swift | 238 +++++++++++++++--- 2 files changed, 254 insertions(+), 33 deletions(-) diff --git a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift index 82c05ea1..161c6424 100644 --- a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift @@ -10,7 +10,7 @@ public struct SuggestionInjector { public struct ExtraInfo { public var didChangeContent = false public var didChangeCursorPosition = false - public var suggestionRange: ClosedRange? + public var modificationRanges: [String: CursorRange] = [:] public var modifications: [Modification] = [] public init() {} } @@ -23,7 +23,6 @@ public struct SuggestionInjector { ) { extraInfo.didChangeContent = true extraInfo.didChangeCursorPosition = true - extraInfo.suggestionRange = nil let start = completion.range.start let end = completion.range.end let suggestionContent = completion.text @@ -85,6 +84,52 @@ public struct SuggestionInjector { line: startLine + toBeInserted.count - 1, character: max(0, cursorCol) ) + extraInfo.modificationRanges[completion.id] = .init(start: start, end: cursorPosition) + } + + public func acceptSuggestions( + intoContentWithoutSuggestion content: inout [String], + cursorPosition: inout CursorPosition, + completions: [CodeSuggestion], + extraInfo: inout ExtraInfo + ) { + var previousCompletion: CodeSuggestion? + + let sortedCompletions = completions.sorted { $0.range.start.line < $1.range.start.line } + + for var completion in sortedCompletions { + defer { previousCompletion = completion } + + // Adjust the position of the completion by the accumulated line count change + if let previousCompletionId = previousCompletion?.id, + let previousRange = extraInfo.modificationRanges[previousCompletionId] + { + let lineCountChange = previousRange + .lineCount - (completion.range.start.line - previousRange.start.line) + completion.position = CursorPosition( + line: completion.position.line + lineCountChange, + character: completion.position.character + ) + completion.range = CursorRange( + start: CursorPosition( + line: completion.range.start.line + lineCountChange, + character: completion.range.start.character + ), + end: CursorPosition( + line: completion.range.end.line + lineCountChange, + character: completion.range.end.character + ) + ) + } + + // Accept the suggestion + acceptSuggestion( + intoContentWithoutSuggestion: &content, + cursorPosition: &cursorPosition, + completion: completion, + extraInfo: &extraInfo + ) + } } func recoverSuffixIfNeeded( diff --git a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index bdd96db9..81d6ef77 100644 --- a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -35,7 +35,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual( @@ -83,7 +89,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -127,7 +139,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -175,7 +193,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -218,7 +242,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 21)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 21)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -258,12 +288,18 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ print("Hello World!") - + """) } @@ -302,7 +338,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 3, character: 1)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 3, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -347,7 +389,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 6, character: 1)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -394,7 +442,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 6, character: 1)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 6, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -445,7 +499,13 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 4, character: 1)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 4, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -499,7 +559,13 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 4, character: 7), + end: .init(line: 7, character: 5)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 7, character: 5)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -551,7 +617,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_remove_the_first_adjacent_placeholder_in_the_last_line( ) async throws { let content = """ @@ -585,7 +651,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_start_from_previous_line_has_emoji_inside() async throws { let content = """ struct 😹😹 { @@ -618,7 +684,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -629,7 +701,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_overlap_with_emoji_in_the_previous_code() async throws { let content = """ struct 😹😹 { @@ -662,7 +734,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -673,7 +751,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_overlap_continue_typing_has_emoji_inside() async throws { let content = """ struct 😹😹 { @@ -710,7 +788,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 1, character: 0), + end: .init(line: 2, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 2, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -721,7 +805,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_replacing_multiple_lines_with_emoji() async throws { let content = """ struct 😹😹 { @@ -758,7 +842,13 @@ final class AcceptSuggestionTests: XCTestCase { XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 4, character: 1)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 4, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -770,8 +860,9 @@ final class AcceptSuggestionTests: XCTestCase { """) } - - func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle() async throws { + + func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle( + ) async throws { let content = """ print("🐢") """ @@ -802,7 +893,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 19)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 19)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -810,7 +907,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character_with_emoji( ) async throws { let content = """ @@ -844,7 +941,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_in_the_middle_single_line() async throws { let content = """ let foobar = 1 @@ -874,7 +971,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 10)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 10)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -882,7 +985,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_in_the_middle_single_line_case_2() async throws { let content = """ let pikachecker = 1 @@ -912,7 +1015,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 23)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 23)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -920,7 +1029,7 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accept_suggestion_rewriting_the_single_line() async throws { let content = """ let foobar = @@ -950,7 +1059,13 @@ final class AcceptSuggestionTests: XCTestCase { ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual( + extraInfo.modificationRanges, + [ + "": CursorRange(start: .init(line: 0, character: 0), + end: .init(line: 0, character: 14)), + ] + ) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) XCTAssertEqual(cursor, .init(line: 0, character: 14)) XCTAssertEqual(lines.joined(separator: ""), """ @@ -958,6 +1073,67 @@ final class AcceptSuggestionTests: XCTestCase { """) } + + func test_accepting_multiple_suggestions_at_a_time() async throws { + let content = """ + let foobar = 1 + let zooKoo = 2 + """ + let text1 = """ + let fooBar = 1 + let fooBar = 2 + """ + let suggestion1 = CodeSuggestion( + id: "1", + text: text1, + position: .init(line: 0, character: 0), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 14) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + let text2 = """ + let zooKoo = 2 + let zooKoo = 3 + """ + let suggestion2 = CodeSuggestion( + id: "2", + text: text2, + position: .init(line: 1, character: 0), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 14) + ), + replacingLines: content.breakLines(appendLineBreakToLastLine: true) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 14) + SuggestionInjector().acceptSuggestions( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completions: [suggestion1, suggestion2], + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 3, character: 14)) + XCTAssertEqual(lines.joined(separator: ""), """ + let fooBar = 1 + let fooBar = 2 + let zooKoo = 2 + let zooKoo = 3 + + """) + XCTAssertEqual(extraInfo.modificationRanges, [ + "1": .init(start: .init(line: 0, character: 0), end: .init(line: 1, character: 14)), + "2": .init(start: .init(line: 2, character: 0), end: .init(line: 3, character: 14)) + ]) + } } extension String { From f9f19c1acf5e6e5aff1bc72c541f95ceeace369f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 23:53:00 +0800 Subject: [PATCH 086/116] Update prompt to code to be able to accept multiple modifications --- .../PseudoCommandHandler.swift | 19 +++-- .../WindowBaseCommandHandler.swift | 78 +++++++++---------- .../PromptToCodePanelView.swift | 55 +++++++------ 3 files changed, 82 insertions(+), 70 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 4001bdd6..8818df6a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -209,18 +209,23 @@ struct PseudoCommandHandler: CommandHandler { try await XcodeInspector.shared.safe.latestActiveXcode? .triggerCopilotCommand(name: "Accept Modification") } catch { - let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI - let now = Date() - if now.timeIntervalSince(last) > 60 * 60 { - Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now - toast.toast(content: """ + do { + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Prompt to Code") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ The app is using a fallback solution to accept suggestions. \ For better experience, please restart Xcode to re-activate the Copilot \ menu item. """, type: .warning) + } + + throw error } - - throw error } } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 109184e3..dee2528d 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -177,59 +177,58 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { let injector = SuggestionInjector() var lines = editor.lines - var ranges = [CursorRange]() var cursorPosition = editor.cursorPosition var extraInfo = SuggestionInjector.ExtraInfo() let store = await Service.shared.guiController.store - if let promptToCode = store.state.promptToCodeGroup.activePromptToCode { + if let promptToCode = await MainActor + .run(body: { store.state.promptToCodeGroup.activePromptToCode }) + { if promptToCode.promptToCodeState.isAttachedToTarget, promptToCode.promptToCodeState.source.documentURL != fileURL { return nil } - for snippet in promptToCode.promptToCodeState.snippets.sorted(by: { a, b in - a.attachedRange.start.line > b.attachedRange.start.line - }) { - let range = { - if promptToCode.promptToCodeState.isAttachedToTarget { - return snippet.attachedRange - } - return editor.selections.first.map { - CursorRange(start: $0.start, end: $0.end) - } ?? CursorRange( - start: editor.cursorPosition, - end: editor.cursorPosition + let suggestions = promptToCode.promptToCodeState.snippets + .map { snippet in + let range = { + if promptToCode.promptToCodeState.isAttachedToTarget { + return snippet.attachedRange + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() + return CodeSuggestion( + id: snippet.id.uuidString, + text: snippet.modifiedCode, + position: range.start, + range: range ) - }() - - ranges.append(range) - - let suggestion = CodeSuggestion( - id: UUID().uuidString, - text: snippet.modifiedCode, - position: range.start, - range: range - ) + } - injector.acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursorPosition, - completion: suggestion, - extraInfo: &extraInfo - ) + injector.acceptSuggestions( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completions: suggestions, + extraInfo: &extraInfo + ) - _ = await Task { @MainActor [cursorPosition] in - await store.send( + for (id, range) in extraInfo.modificationRanges { + _ = await MainActor.run { + store.send( .promptToCodeGroup(.updatePromptToCodeRange( id: promptToCode.id, - snippetId: snippet.id, - range: .init(start: range.start, end: cursorPosition) + snippetId: .init(uuidString: id) ?? .init(), + range: range )) - ).finish() - }.value + ) + } } _ = await MainActor.run { @@ -242,7 +241,8 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { return .init( content: String(lines.joined(separator: "")), - newSelections: ranges, + newSelections: extraInfo.modificationRanges.values + .sorted(by: { $0.start.line <= $1.start.line }), modifications: extraInfo.modifications ) } @@ -436,10 +436,10 @@ extension WindowBaseCommandHandler { content: editor.content, lines: editor.lines ), - snippets: IdentifiedArray(snippets), + snippets: IdentifiedArray(uniqueElements: snippets), instruction: newPrompt ?? "", extraSystemPrompt: newExtraSystemPrompt ?? "", - isAttachedToTarget: false + isAttachedToTarget: true )), indentSize: filespace.codeMetadata.indentSize ?? 4, usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 2f96e09c..0c21a659 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -191,11 +191,13 @@ extension PromptToCodePanelView { if !isResponding || isRespondingButCodeIsReady { HStack { Menu { - Toggle( - "Always accept and continue", - isOn: $store.isContinuous.animation(.easeInOut(duration: 0.1)) - ) - .toggleStyle(.checkbox) + WithPerceptionTracking { + Toggle( + "Always accept and continue", + isOn: $store.isContinuous.animation(.easeInOut(duration: 0.1)) + ) + .toggleStyle(.checkbox) + } } label: { Image(systemName: "gearshape.fill") .foregroundStyle(.secondary) @@ -314,27 +316,32 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { ScrollView { - VStack(spacing: 0) { - Spacer(minLength: 56) - + WithPerceptionTracking { VStack(spacing: 0) { - let language = store.promptToCodeState.source.language - let isAttached = store.promptToCodeState.isAttachedToTarget - ForEach(store.scope( - state: \.snippetPanels, - action: \.snippetPanel - )) { snippetStore in - if snippetStore.id != store.promptToCodeState.snippets.last?.id { - Divider() + Spacer(minLength: 56) + + VStack(spacing: 0) { + let language = store.promptToCodeState.source.language + let isAttached = store.promptToCodeState.isAttachedToTarget + let lastId = store.promptToCodeState.snippets.last?.id + ForEach(store.scope( + state: \.snippetPanels, + action: \.snippetPanel + )) { snippetStore in + WithPerceptionTracking { + if snippetStore.id != lastId { + Divider() + } + + SnippetPanelView( + store: snippetStore, + language: language, + codeForegroundColor: codeForegroundColor ?? .primary, + codeBackgroundColor: codeBackgroundColor, + isAttached: isAttached + ) + } } - - SnippetPanelView( - store: snippetStore, - language: language, - codeForegroundColor: codeForegroundColor ?? .primary, - codeBackgroundColor: codeBackgroundColor, - isAttached: isAttached - ) } } } From 359dc7d7914f914b880db833b9c9ce425e3f7bec Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 6 Sep 2024 23:53:08 +0800 Subject: [PATCH 087/116] Fix tests --- .../Filespace+SuggestionService.swift | 19 ++++++++++++++++++- ...singSuggestionServiceMiddlewareTests.swift | 15 ++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index f2f6badd..25afb616 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -104,7 +104,7 @@ extension Filespace { ) -> Bool { // cursor is not even moved during the generation. if alwaysTrueIfCursorNotMoved, cursorPosition == suggestion.position { return true } - + // cursor has moved to another line if cursorPosition.line != suggestion.position.line { return false } @@ -219,6 +219,23 @@ extension Filespace { completion: pseudoSuggestion, extraInfo: &extraInfo ) + + // We want that finish typing a partial suggestion should also make no difference. + if let lastOriginalLine = originalEditingLines.last, + cursorPosition.character < lastOriginalLine.utf16.count, + // But we also want to separate this case from the case that the suggestion is + // shortening the last line. Which does make a difference. + suggestion.range.end.character < lastOriginalLine.utf16.count - 1 // for line ending + { + let editingLinesPrefix = editingLines.dropLast() + let originalEditingLinesPrefix = originalEditingLines.dropLast() + if editingLinesPrefix != originalEditingLinesPrefix { + return false + } + let lastEditingLine = editingLines.last ?? "\n" + return lastOriginalLine.hasPrefix(lastEditingLine.dropLast(1)) // for line ending + } + return editingLines == originalEditingLines } } diff --git a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift index 3ed29251..214e6ad8 100644 --- a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift +++ b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift @@ -241,7 +241,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { id: "1", text: "hello world", position: .init(line: 0, character: 1), - range: .init(startPair: (0, 0), endPair: (0, 1)) + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] ), ]) } @@ -275,7 +276,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { id: "1", text: "", position: .init(line: 0, character: 0), - range: .init(startPair: (0, 0), endPair: (0, 0)) + range: .init(startPair: (0, 0), endPair: (0, 0)), + middlewareComments: ["Removed redundant closing parenthesis."] ), ]) } @@ -309,7 +311,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { id: "1", text: "hello world", position: .init(line: 0, character: 1), - range: .init(startPair: (0, 0), endPair: (0, 1)) + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] ), ]) } @@ -343,7 +346,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { id: "1", text: "hello world", position: .init(line: 0, character: 1), - range: .init(startPair: (0, 0), endPair: (0, 1)) + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] ), ]) } @@ -377,7 +381,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { id: "1", text: "hello world", position: .init(line: 0, character: 1), - range: .init(startPair: (0, 0), endPair: (0, 1)) + range: .init(startPair: (0, 0), endPair: (0, 1)), + middlewareComments: ["Removed redundant closing parenthesis."] ), ]) } From 0af626504fced8c1ece66d76869b8d2cb04abd0b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 7 Sep 2024 00:30:25 +0800 Subject: [PATCH 088/116] Make text contents always wrap in code block --- .../ChatGPTChatTab/Views/ThemedMarkdownText.swift | 6 ++++-- .../SuggestionPanelContent/PromptToCodePanelView.swift | 10 +++++++--- Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift | 1 - 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift index 15da1780..1efa86f5 100644 --- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift @@ -15,9 +15,9 @@ struct ThemedMarkdownText: View { let content: MarkdownContent init(_ text: String) { - self.content = .init(text) + content = .init(text) } - + init(_ content: MarkdownContent) { self.content = content } @@ -71,6 +71,8 @@ extension MarkdownUI.Theme { } .codeBlock { configuration in let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + || ["plaintext", "text", "markdown", "sh", "bash", "shell", "latex", "tex"] + .contains(configuration.language) if wrapCode { AsyncCodeBlockView( diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 0c21a659..b7fd4dbf 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -194,7 +194,8 @@ extension PromptToCodePanelView { WithPerceptionTracking { Toggle( "Always accept and continue", - isOn: $store.isContinuous.animation(.easeInOut(duration: 0.1)) + isOn: $store.isContinuous + .animation(.easeInOut(duration: 0.1)) ) .toggleStyle(.checkbox) } @@ -319,7 +320,7 @@ extension PromptToCodePanelView { WithPerceptionTracking { VStack(spacing: 0) { Spacer(minLength: 56) - + VStack(spacing: 0) { let language = store.promptToCodeState.source.language let isAttached = store.promptToCodeState.isAttachedToTarget @@ -332,7 +333,7 @@ extension PromptToCodePanelView { if snippetStore.id != lastId { Divider() } - + SnippetPanelView( store: snippetStore, language: language, @@ -471,6 +472,9 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { if !store.snippet.modifiedCode.isEmpty { + let wrapCode = wrapCode || + [CodeLanguage.plaintext, .builtIn(.markdown), .builtIn(.shellscript), + .builtIn(.tex)].contains(language) if wrapCode { CodeBlockInContent( store: store, diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 8351864a..375dc46f 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -524,4 +524,3 @@ extension AsyncCodeBlock { return UpdateContent() .frame(width: 400, height: 200) } - From b8f613be07b9ccb085812bc25a959c8811a88167 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 7 Sep 2024 01:45:02 +0800 Subject: [PATCH 089/116] Add accept button menu --- .../PromptToCodePanelView.swift | 184 +++++++++++++----- 1 file changed, 140 insertions(+), 44 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index b7fd4dbf..62f53061 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -173,7 +173,6 @@ extension PromptToCodePanelView { struct ActionButtons: View { @Perception.Bindable var store: StoreOf - @Environment(\.modifierFlags) var modifierFlags var body: some View { WithPerceptionTracking { @@ -201,9 +200,13 @@ extension PromptToCodePanelView { } } label: { Image(systemName: "gearshape.fill") + .resizable() + .scaledToFit() .foregroundStyle(.secondary) + .frame(width: 16) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) } - .frame(width: 16) .buttonStyle(.plain) Button(action: { @@ -215,50 +218,10 @@ extension PromptToCodePanelView { .keyboardShortcut("w", modifiers: [.command]) if !isCodeEmpty { - let defaultModeIsContinuous = store.isContinuous - - Button(action: { - switch ( - modifierFlags.contains(.option), - defaultModeIsContinuous - ) { - case (true, true): - store.send(.acceptButtonTapped) - case (false, true): - store.send(.acceptAndContinueButtonTapped) - case (true, false): - store.send(.acceptAndContinueButtonTapped) - case (false, false): - store.send(.acceptButtonTapped) - } - }) { - switch ( - isAttached, - modifierFlags.contains(.option), - defaultModeIsContinuous - ) { - case (true, true, true): - Text("Accept(βŒ₯ + ⌘ + ⏎)") - case (true, false, true): - Text("Accept and Continue(⌘ + ⏎)") - case (true, true, false): - Text("Accept and Continue(βŒ₯ + ⌘ + ⏎)") - case (true, false, false): - Text("Accept(⌘ + ⏎)") - case (false, true, true): - Text("Replace(βŒ₯ + ⌘ + ⏎)") - case (false, false, true): - Text("Replace and Continue(⌘ + ⏎)") - case (false, true, false): - Text("Replace and Continue(βŒ₯ + ⌘ + ⏎)") - case (false, false, false): - Text("Replace(⌘ + ⏎)") - } - } - .buttonStyle(CommandButtonStyle(color: .accentColor)) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + AcceptButton(store: store) } } + .fixedSize() .padding(8) .background( .regularMaterial, @@ -276,6 +239,139 @@ extension PromptToCodePanelView { } } } + + struct AcceptButton: View { + let store: StoreOf + @Environment(\.modifierFlags) var modifierFlags + + struct TheButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + Rectangle() + .fill(Color.accentColor.opacity(configuration.isPressed ? 0.8 : 1)) + ) + } + } + + var body: some View { + WithPerceptionTracking { + let defaultModeIsContinuous = store.isContinuous + let isAttached = store.promptToCodeState.isAttachedToTarget + + HStack(spacing: 0) { + Button(action: { + switch ( + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true): + store.send(.acceptButtonTapped) + case (false, true): + store.send(.acceptAndContinueButtonTapped) + case (true, false): + store.send(.acceptAndContinueButtonTapped) + case (false, false): + store.send(.acceptButtonTapped) + } + }) { + Group { + switch ( + isAttached, + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true, true): + Text("Accept(βŒ₯ + ⌘ + ⏎)") + case (true, false, true): + Text("Accept and Continue(⌘ + ⏎)") + case (true, true, false): + Text("Accept and Continue(βŒ₯ + ⌘ + ⏎)") + case (true, false, false): + Text("Accept(⌘ + ⏎)") + case (false, true, true): + Text("Replace(βŒ₯ + ⌘ + ⏎)") + case (false, false, true): + Text("Replace and Continue(⌘ + ⏎)") + case (false, true, false): + Text("Replace and Continue(βŒ₯ + ⌘ + ⏎)") + case (false, false, false): + Text("Replace(⌘ + ⏎)") + } + } + .padding(.vertical, 4) + .padding(.leading, 8) + .padding(.trailing, 4) + } + .buttonStyle(TheButtonStyle()) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + .background { + Button(action: { + switch ( + modifierFlags.contains(.option), + defaultModeIsContinuous + ) { + case (true, true): + store.send(.acceptAndContinueButtonTapped) + case (false, true): + store.send(.acceptButtonTapped) + case (true, false): + store.send(.acceptButtonTapped) + case (false, false): + store.send(.acceptAndContinueButtonTapped) + } + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command, .option]) + } + + Divider() + + Menu { + WithPerceptionTracking { + if defaultModeIsContinuous { + Button(action: { + store.send(.acceptButtonTapped) + }) { + Text("Accept(βŒ₯ + ⌘ + ⏎)") + } + } else { + Button(action: { + store.send(.acceptAndContinueButtonTapped) + }) { + Text("Accept and Continue(βŒ₯ + ⌘ + ⏎)") + } + } + } + } label: { + Text(Image(systemName: "chevron.down")) + .font(.footnote.weight(.bold)) + .scaleEffect(0.8) + .foregroundStyle(.white.opacity(0.8)) + .frame(maxHeight: .infinity) + .padding(.leading, 1) + .padding(.trailing, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .fixedSize() + + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.accentColor) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + } + } + } + } } struct Content: View { From c15858dd43f733ef84c465d64bf9e31c7ea0776a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 7 Sep 2024 16:30:08 +0800 Subject: [PATCH 090/116] Adjust UI --- .../SuggestionPanelContent/PromptToCodePanelView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 62f53061..7efc5f86 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -55,8 +55,9 @@ extension PromptToCodePanelView { Button(action: { store.send(.revertButtonTapped) }, label: { - HStack(spacing: 2) { + HStack(spacing: 4) { Text(Image(systemName: "arrow.uturn.backward.circle.fill")) + .foregroundStyle(.secondary) Text(previousStep.instruction) .lineLimit(1) .truncationMode(.tail) @@ -67,7 +68,7 @@ extension PromptToCodePanelView { }) .buttonStyle(.plain) .disabled(store.promptToCodeState.isGenerating) - .padding(4) + .padding(6) Divider() } From a78effe794a8077146627841ecde913bdd9af276 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 7 Sep 2024 21:08:31 +0800 Subject: [PATCH 091/116] Fix that ranges are incorrect after accepting multiple suggestions --- .../SuggestionInjector.swift | 54 +++++-- .../AcceptSuggestionTests.swift | 149 +++++++++++++++--- 2 files changed, 168 insertions(+), 35 deletions(-) diff --git a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift index 161c6424..00b817d0 100644 --- a/Tool/Sources/SuggestionInjector/SuggestionInjector.swift +++ b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift @@ -93,19 +93,39 @@ public struct SuggestionInjector { completions: [CodeSuggestion], extraInfo: inout ExtraInfo ) { - var previousCompletion: CodeSuggestion? - - let sortedCompletions = completions.sorted { $0.range.start.line < $1.range.start.line } + let sortedCompletions = completions.sorted { + if $0.range.start.line < $1.range.start.line { + true + } else if $0.range.start.line == $1.range.start.line { + $0.range.start.character < $1.range.start.character + } else { + false + } + } for var completion in sortedCompletions { - defer { previousCompletion = completion } - - // Adjust the position of the completion by the accumulated line count change - if let previousCompletionId = previousCompletion?.id, - let previousRange = extraInfo.modificationRanges[previousCompletionId] - { - let lineCountChange = previousRange - .lineCount - (completion.range.start.line - previousRange.start.line) + let lineCountChange: Int = { + var accumulation = 0 + let endIndex = completion.range.start.line + for modification in extraInfo.modifications { + switch modification { + case let .deleted(range): + if range.lowerBound <= endIndex { + accumulation -= range.count + if range.upperBound >= endIndex { + accumulation += range.upperBound - endIndex + } + } + case let .inserted(index, lines): + if index <= endIndex { + accumulation += lines.count + } + } + } + return accumulation + }() + + if lineCountChange != 0 { completion.position = CursorPosition( line: completion.position.line + lineCountChange, character: completion.position.character @@ -122,6 +142,18 @@ public struct SuggestionInjector { ) } + completion.replacingLines = { + let start = completion.range.start.line + let end = completion.range.end.line + if start >= content.endIndex { + return [] + } + if end < content.endIndex { + return Array(content[start...end]) + } + return Array(content[start...]) + }() + // Accept the suggestion acceptSuggestion( intoContentWithoutSuggestion: &content, diff --git a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index 81d6ef77..3a2b9499 100644 --- a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -1073,40 +1073,75 @@ final class AcceptSuggestionTests: XCTestCase { """) } - + func test_accepting_multiple_suggestions_at_a_time() async throws { let content = """ - let foobar = 1 - let zooKoo = 2 + protocol Definition { + var id: String + var name: String + } + + struct Foo { + + } + + struct Bar { + + } + + let foo = Foo() + + struct Baz {} """ let text1 = """ - let fooBar = 1 - let fooBar = 2 + struct Foo: Definition { + var id: String + var name: String + } """ let suggestion1 = CodeSuggestion( id: "1", text: text1, - position: .init(line: 0, character: 0), + position: .init(line: 5, character: 0), range: .init( - start: .init(line: 0, character: 0), - end: .init(line: 0, character: 14) + start: .init(line: 5, character: 0), + end: .init(line: 7, character: 1) ), - replacingLines: content.breakLines(appendLineBreakToLastLine: true) + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[5...7]) ) let text2 = """ - let zooKoo = 2 - let zooKoo = 3 + struct Bar: Definition { + var id: String + var name: String + } """ let suggestion2 = CodeSuggestion( id: "2", text: text2, - position: .init(line: 1, character: 0), + position: .init(line: 9, character: 0), range: .init( - start: .init(line: 1, character: 0), - end: .init(line: 1, character: 14) + start: .init(line: 9, character: 0), + end: .init(line: 11, character: 1) ), - replacingLines: content.breakLines(appendLineBreakToLastLine: true) + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[9...11]) + ) + + let text3 = """ + struct Baz: Definition { + var id: String + var name: String + } + """ + let suggestion3 = CodeSuggestion( + id: "3", + text: text3, + position: .init(line: 15, character: 0), + range: .init( + start: .init(line: 15, character: 0), + end: .init(line: 15, character: 13) + ), + replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[15...15]) ) var extraInfo = SuggestionInjector.ExtraInfo() @@ -1115,23 +1150,89 @@ final class AcceptSuggestionTests: XCTestCase { SuggestionInjector().acceptSuggestions( intoContentWithoutSuggestion: &lines, cursorPosition: &cursor, - completions: [suggestion1, suggestion2], + completions: [suggestion1, suggestion2, suggestion3], extraInfo: &extraInfo ) XCTAssertTrue(extraInfo.didChangeContent) XCTAssertTrue(extraInfo.didChangeCursorPosition) XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 3, character: 14)) + XCTAssertEqual(cursor, .init(line: 20, character: 1)) XCTAssertEqual(lines.joined(separator: ""), """ - let fooBar = 1 - let fooBar = 2 - let zooKoo = 2 - let zooKoo = 3 - + protocol Definition { + var id: String + var name: String + } + + struct Foo: Definition { + var id: String + var name: String + } + + struct Bar: Definition { + var id: String + var name: String + } + + let foo = Foo() + + struct Baz: Definition { + var id: String + var name: String + } + """) XCTAssertEqual(extraInfo.modificationRanges, [ - "1": .init(start: .init(line: 0, character: 0), end: .init(line: 1, character: 14)), - "2": .init(start: .init(line: 2, character: 0), end: .init(line: 3, character: 14)) + "1": .init(start: .init(line: 5, character: 0), end: .init(line: 8, character: 1)), + "2": .init(start: .init(line: 10, character: 0), end: .init(line: 13, character: 1)), + "3": .init(start: .init(line: 17, character: 0), end: .init(line: 20, character: 1)), + ]) + } + + func test_accepting_multiple_same_line_suggestions_at_a_time() async throws { + let content = "let foo = 1\n" + let text1 = "berry" + let suggestion1 = CodeSuggestion( + id: "1", + text: text1, + position: .init(line: 0, character: 4), + range: .init( + start: .init(line: 0, character: 4), + end: .init(line: 0, character: 7) + ), + replacingLines: [content] + ) + + let text2 = """ + 200 + """ + let suggestion2 = CodeSuggestion( + id: "2", + text: text2, + position: .init(line: 0, character: 10), + range: .init( + start: .init(line: 0, character: 10), + end: .init(line: 0, character: 11) + ), + replacingLines: [content] + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 0) + SuggestionInjector().acceptSuggestions( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completions: [suggestion1, suggestion2], + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 15)) + XCTAssertEqual(lines.joined(separator: ""), "let berry = 200\n") + XCTAssertEqual(extraInfo.modificationRanges, [ + "1": .init(start: .init(line: 0, character: 4), end: .init(line: 0, character: 9)), + "2": .init(start: .init(line: 0, character: 12), end: .init(line: 0, character: 15)), ]) } } From 9776ceedae55578ec513f80180a2bfb01dec1875 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 7 Sep 2024 21:09:07 +0800 Subject: [PATCH 092/116] Tweaks multiple modification prompt to code --- .../WindowBaseCommandHandler.swift | 40 +++++++++++++++++-- .../FeatureReducers/PromptToCodeGroup.swift | 2 +- .../FeatureReducers/PromptToCodePanel.swift | 3 ++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index dee2528d..6e49132d 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -365,7 +365,38 @@ extension WindowBaseCommandHandler { let codeLanguage = languageIdentifierFromFileURL(fileURL) - let snippets = editor.selections.map { selection in + let selections: [CursorRange] = { + var all = [CursorRange]() + + // join the ranges if they overlaps in line + + for selection in editor.selections { + let range = CursorRange(start: selection.start, end: selection.end) + + func intersect(_ lhs: CursorRange, _ rhs: CursorRange) -> Bool { + lhs.start.line <= rhs.end.line && lhs.end.line >= rhs.start.line + } + + if let last = all.last, intersect(last, range) { + all[all.count - 1] = CursorRange( + start: .init( + line: min(last.start.line, range.start.line), + character: min(last.start.character, range.start.character) + ), + end: .init( + line: max(last.end.line, range.end.line), + character: max(last.end.character, range.end.character) + ) + ) + } else if !range.isEmpty { + all.append(range) + } + } + + return all + }() + + let snippets = selections.map { selection in guard selection.start != selection.end else { return PromptToCodeSnippet( startLineIndex: selection.start.line, @@ -373,7 +404,7 @@ extension WindowBaseCommandHandler { modifiedCode: "", description: "", error: "", - attachedRange: .init(start: selection.start, end: selection.end) + attachedRange: selection ) } var selection = selection @@ -399,7 +430,10 @@ extension WindowBaseCommandHandler { // indentation. selection.start = .init(line: selection.start.line, character: 0) } - let selectedCode = editor.selectedCode(in: selection) + let selectedCode = editor.selectedCode(in: .init( + start: selection.start, + end: selection.end + )) return PromptToCodeSnippet( startLineIndex: selection.start.line, originalCode: selectedCode, diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 82a856a9..114389e3 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -80,7 +80,7 @@ public struct PromptToCodeGroup { return .none case let .discardAcceptedPromptToCodeIfNotContinuous(id): - state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous } + state.promptToCodes.removeAll { $0.id == id && $0.hasEnded } return .none case let .updateActivePromptToCode(documentURL): diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 0007c797..6f2d67eb 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -34,6 +34,8 @@ public struct PromptToCodePanel { public var canRevert: Bool { !promptToCodeState.history.isEmpty } public var generateDescriptionRequirement: Bool + + public var hasEnded = false public var snippetPanels: IdentifiedArrayOf { get { @@ -223,6 +225,7 @@ public struct PromptToCodePanel { return .cancel(id: CancellationKey.modifyCode(state.id)) case .acceptButtonTapped: + state.hasEnded = true return .run { _ in await commandHandler.acceptPromptToCode() activatePreviousActiveXcode() From b29d1aa34f954edfc98fdd73916a116e28efdd31 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 01:22:47 +0800 Subject: [PATCH 093/116] Fix message count --- Core/Sources/ChatPlugin/AskChatGPT.swift | 3 ++- Core/Sources/ChatPlugin/CallAIFunction.swift | 3 ++- .../ContextAwareAutoManagedChatGPTMemory.swift | 3 ++- .../PromptToCodeService/OpenAIPromptToCodeService.swift | 3 ++- .../PromptToCodeService/PromptToCodeServiceType.swift | 2 ++ Tool/Sources/OpenAIService/LegacyChatGPTService.swift | 3 ++- .../OpenAIService/Memory/AutoManagedChatGPTMemory.swift | 8 ++++---- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/ChatPlugin/AskChatGPT.swift index 6defedf6..b942a7de 100644 --- a/Core/Sources/ChatPlugin/AskChatGPT.swift +++ b/Core/Sources/ChatPlugin/AskChatGPT.swift @@ -12,7 +12,8 @@ public func askChatGPT( let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ) let service = LegacyChatGPTService( memory: memory, diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/ChatPlugin/CallAIFunction.swift index 8c38e651..20f7a01d 100644 --- a/Core/Sources/ChatPlugin/CallAIFunction.swift +++ b/Core/Sources/ChatPlugin/CallAIFunction.swift @@ -22,7 +22,8 @@ func callAIFunction( memory: AutoManagedChatGPTMemory( systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.", configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ), configuration: configuration ) diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift index ac44d87c..32d65694 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -22,7 +22,8 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { memory = AutoManagedChatGPTMemory( systemPrompt: "", configuration: configuration, - functionProvider: functionProvider + functionProvider: functionProvider, + maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount) ) contextController = DynamicContextController( memory: memory, diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index c10ae7b6..2cc146bd 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -179,7 +179,8 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ) let chatGPTService = LegacyChatGPTService( memory: memory, diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift index 609af1d4..07acdb87 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -62,6 +62,8 @@ public extension DependencyValues { import ContextAwarePromptToCodeService extension ContextAwarePromptToCodeService: PromptToCodeServiceType { + public func stopResponding() {} + public func modifyCode( code: String, requirement: String, diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift index 24beacfa..d33f7e7f 100644 --- a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift +++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift @@ -26,7 +26,8 @@ public class LegacyChatGPTService: LegacyChatGPTServiceType { memory: ChatGPTMemory = AutoManagedChatGPTMemory( systemPrompt: "", configuration: UserPreferenceChatGPTConfiguration(), - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: .max ), configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(), functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider() diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 237ce417..6da5e86b 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -38,6 +38,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { public var retrievedContent: [ChatMessage.Reference] = [] public var configuration: ChatGPTConfiguration public var functionProvider: ChatGPTFunctionProvider + public var maxNumberOfMessages: Int var onHistoryChange: () -> Void = {} @@ -47,6 +48,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { systemPrompt: String, configuration: ChatGPTConfiguration, functionProvider: ChatGPTFunctionProvider, + maxNumberOfMessages: Int = .max, composeHistory: @escaping HistoryComposer = { /// Default Format: /// ``` @@ -70,6 +72,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { self.configuration = configuration self.functionProvider = functionProvider self.composeHistory = composeHistory + self.maxNumberOfMessages = maxNumberOfMessages } public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { @@ -110,10 +113,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { extension AutoManagedChatGPTMemory { /// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb - func generateSendingHistory( - maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), - strategy: AutoManagedChatGPTMemoryStrategy - ) async -> ChatGPTPrompt { + func generateSendingHistory(strategy: AutoManagedChatGPTMemoryStrategy) async -> ChatGPTPrompt { // handle no function support models let ( From 37645ef9301d6651bf2d772bb591759eae6c6539 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 01:23:47 +0800 Subject: [PATCH 094/116] Adjust UI --- Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift | 2 +- Tool/Sources/SharedUIComponents/CodeBlock.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 375dc46f..5e36a852 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -85,7 +85,7 @@ public struct AsyncCodeBlock: View { // chat: hid .font(.init(font)) .padding(.leading, 4) .padding(.trailing) - .padding(.top, commonPrecedingSpaceCount > 0 ? 12 : 4) + .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4) .padding(.bottom, 4) .onAppear { storage.dimmedCharacterCount = dimmedCharacterCount diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift index 403cb9a4..86cb4fde 100644 --- a/Tool/Sources/SharedUIComponents/CodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -85,7 +85,7 @@ public struct CodeBlock: View { .font(.init(font)) .padding(.leading, 4) .padding(.trailing) - .padding(.top, commonPrecedingSpaceCount > 0 ? 12 : 4) + .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4) .padding(.bottom, 4) } From 358187364ec7eebfa99b3044b9be26f22a3bc42b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 01:24:00 +0800 Subject: [PATCH 095/116] Update --- .../WindowBaseCommandHandler.swift | 2 +- .../PromptToCodePanelView.swift | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 6e49132d..939b9652 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -388,7 +388,7 @@ extension WindowBaseCommandHandler { character: max(last.end.character, range.end.character) ) ) - } else if !range.isEmpty { + } else { all.append(range) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 7efc5f86..3e1aab22 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -187,7 +187,6 @@ extension PromptToCodePanelView { && !isCodeEmpty && !isDescriptionEmpty } - let isAttached = store.promptToCodeState.isAttachedToTarget if !isResponding || isRespondingButCodeIsReady { HStack { Menu { @@ -422,6 +421,7 @@ extension PromptToCodePanelView { let language = store.promptToCodeState.source.language let isAttached = store.promptToCodeState.isAttachedToTarget let lastId = store.promptToCodeState.snippets.last?.id + let isGenerating = store.promptToCodeState.isGenerating ForEach(store.scope( state: \.snippetPanels, action: \.snippetPanel @@ -436,7 +436,8 @@ extension PromptToCodePanelView { language: language, codeForegroundColor: codeForegroundColor ?? .primary, codeBackgroundColor: codeBackgroundColor, - isAttached: isAttached + isAttached: isAttached, + isGenerating: isGenerating ) } } @@ -455,6 +456,7 @@ extension PromptToCodePanelView { let codeForegroundColor: Color let codeBackgroundColor: Color let isAttached: Bool + let isGenerating: Bool var body: some View { WithPerceptionTracking { @@ -463,7 +465,8 @@ extension PromptToCodePanelView { DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) CodeContent( store: store, - language: language, + language: language, + isGenerating: isGenerating, codeForegroundColor: codeForegroundColor ) SnippetTitleBar( @@ -562,6 +565,7 @@ extension PromptToCodePanelView { struct CodeContent: View { let store: StoreOf let language: CodeLanguage + let isGenerating: Bool let codeForegroundColor: Color? @AppStorage(\.wrapCodeInPromptToCode) var wrapCode @@ -595,11 +599,19 @@ extension PromptToCodePanelView { } } } else { - Text("Thinking...") - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .scaleEffect(x: 1, y: -1, anchor: .center) + if isGenerating { + Text("Thinking...") + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + Text("Enter your requirements to generate code.") + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .scaleEffect(x: 1, y: -1, anchor: .center) + } } } } From 5543c782b8a6b6f7abb1d2ae691a350aacb5c01c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 03:11:27 +0800 Subject: [PATCH 096/116] Add TemplateChatGPTMemory --- .../Memory/TemplateChatGPTMemory.swift | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift new file mode 100644 index 00000000..129474aa --- /dev/null +++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift @@ -0,0 +1,256 @@ +import ChatBasic +import Foundation +import Logger +import Preferences +import TokenEncoder + +/// A memory that automatically manages the history according to max tokens and the template rules. +public actor TemplateChatGPTMemory: ChatGPTMemory { + public private(set) var memoryTemplate: MemoryTemplate + public var history: [ChatMessage] { memoryTemplate.resolved() } + public var configuration: ChatGPTConfiguration + public var functionProvider: ChatGPTFunctionProvider + + public init( + memoryTemplate: MemoryTemplate, + configuration: ChatGPTConfiguration, + functionProvider: ChatGPTFunctionProvider + ) { + self.memoryTemplate = memoryTemplate + self.configuration = configuration + self.functionProvider = functionProvider + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { + update(&memoryTemplate.followUpMessages) + } + + public func generatePrompt() async -> ChatGPTPrompt { + let strategy: AutoManagedChatGPTMemoryStrategy = switch configuration.model?.format { + case .googleAI: AutoManagedChatGPTMemory.GoogleAIStrategy(configuration: configuration) + default: AutoManagedChatGPTMemory.OpenAIStrategy() + } + + var memoryTemplate = self.memoryTemplate + func checkTokenCount() async -> Bool { + let history = self.history + var tokenCount = 0 + for message in history { + tokenCount += await strategy.countToken(message) + } + for function in functionProvider.functions { + tokenCount += await strategy.countToken(function) + } + return tokenCount <= configuration.maxTokens - configuration.minimumReplyTokens + } + + while !(await checkTokenCount()) { + do { + try memoryTemplate.truncate() + } catch { + Logger.service.error("Failed to truncate prompt template: \(error)") + break + } + } + + return ChatGPTPrompt(history: memoryTemplate.resolved()) + } +} + +public struct MemoryTemplate { + public struct Message { + public struct DynamicContent: ExpressibleByStringLiteral { + public enum Content: ExpressibleByStringLiteral { + case text(String) + case list([String], formatter: ([String]) -> String) + + public init(stringLiteral value: String) { + self = .text(value) + } + } + + public var content: Content + public var truncatePriority: Int = 0 + public var isEmpty: Bool { + switch content { + case let .text(text): + return text.isEmpty + case let .list(list, _): + return list.isEmpty + } + } + + public init(stringLiteral value: String) { + content = .text(value) + } + + public init(content: Content, truncatePriority: Int = 0) { + self.content = content + self.truncatePriority = truncatePriority + } + } + + public var chatMessage: ChatMessage + public var dynamicContent: [DynamicContent] = [] + public var truncatePriority: Int = 0 + + public func resolved() -> ChatMessage? { + var baseMessage = chatMessage + guard !dynamicContent.isEmpty else { + if baseMessage.isEmpty { return nil } + return baseMessage + } + + let contents: [String] = dynamicContent.compactMap { content in + if content.isEmpty { return nil } + switch content.content { + case let .text(text): + return text + case let .list(list, formatter): + return formatter(list) + } + } + + baseMessage.content = contents.joined(separator: "\n\n") + + return baseMessage + } + + public var isEmpty: Bool { + if !dynamicContent.isEmpty { return dynamicContent.allSatisfy { $0.isEmpty } } + if let toolCalls = chatMessage.toolCalls, !toolCalls.isEmpty { + return false + } + if let content = chatMessage.content, !content.isEmpty { + return false + } + return true + } + + public init( + chatMessage: ChatMessage, + dynamicContent: [DynamicContent] = [], + truncatePriority: Int = 0 + ) { + self.chatMessage = chatMessage + self.dynamicContent = dynamicContent + self.truncatePriority = truncatePriority + } + } + + public var messages: [Message] + public var followUpMessages: [ChatMessage] + + let truncateRule: (( + _ messages: inout [Message], + _ followUpMessages: inout [ChatMessage] + ) throws -> Void)? + + func resolved() -> [ChatMessage] { + messages.compactMap { message in message.resolved() } + followUpMessages + } + + func truncated() throws -> MemoryTemplate { + var copy = self + try copy.truncate() + return copy + } + + mutating func truncate() throws { + if let truncateRule = truncateRule { + try truncateRule(&messages, &followUpMessages) + return + } + + try Self.defaultTruncateRule(&messages, &followUpMessages) + } + + public static func defaultTruncateRule( + _ messages: inout [Message], + _ followUpMessages: inout [ChatMessage] + ) throws { + // Remove the oldest followup messages when available. + + if followUpMessages.count > 20 { + followUpMessages.removeFirst(followUpMessages.count / 2) + return + } + + if followUpMessages.count > 2 { + if followUpMessages.count.isMultiple(of: 2) { + followUpMessages.removeFirst(2) + } else { + followUpMessages.removeFirst(1) + } + return + } + + // Remove according to the priority. + + var truncatingMessageIndex: Int? + for (index, message) in messages.enumerated() { + if message.truncatePriority <= 0 { continue } + if let previousIndex = truncatingMessageIndex, + message.truncatePriority > messages[previousIndex].truncatePriority + { + truncatingMessageIndex = index + } + } + + guard let truncatingMessageIndex else { throw CancellationError() } + var truncatingMessage: Message { + get { messages[truncatingMessageIndex] } + set { messages[truncatingMessageIndex] = newValue } + } + + if truncatingMessage.isEmpty { + messages.remove(at: truncatingMessageIndex) + return + } + + truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty }) + + var truncatingContentIndex: Int? + for (index, content) in truncatingMessage.dynamicContent.enumerated() { + if content.isEmpty { continue } + if let previousIndex = truncatingContentIndex, + content.truncatePriority > truncatingMessage.dynamicContent[previousIndex] + .truncatePriority + { + truncatingContentIndex = index + } + } + + guard let truncatingContentIndex else { throw CancellationError() } + var truncatingContent: Message.DynamicContent { + get { truncatingMessage.dynamicContent[truncatingContentIndex] } + set { truncatingMessage.dynamicContent[truncatingContentIndex] = newValue } + } + + switch truncatingContent.content { + case .text: + truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) + case let .list(list, formatter: formatter): + let count = list.count * 2 / 3 + if count > 0 { + truncatingContent.content = .list( + Array(list.prefix(count)), + formatter: formatter + ) + } else { + truncatingMessage.dynamicContent.remove(at: truncatingContentIndex) + } + } + } + + public init( + messages: [Message], + followUpMessages: [ChatMessage] = [], + truncateRule: ((inout [Message], inout [ChatMessage]) -> Void)? = nil + ) { + self.messages = messages + self.truncateRule = truncateRule + self.followUpMessages = followUpMessages + } +} + From 08b666d8582d65f091c98dbd01b9775daa7279c2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 16:23:26 +0800 Subject: [PATCH 097/116] Fix dependencies --- Tool/Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tool/Package.swift b/Tool/Package.swift index 20f61019..874569fe 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -202,6 +202,10 @@ let package = Package( dependencies: [ "SuggestionBasic", .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), ] ), From 6a444a44b2b896c9fe509bc575b91bf276622bd5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 16:23:39 +0800 Subject: [PATCH 098/116] Lower the default activation delay --- Tool/Sources/AppActivator/AppActivator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift index b50f3bf4..cb5ec194 100644 --- a/Tool/Sources/AppActivator/AppActivator.swift +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -3,7 +3,7 @@ import Dependencies import XcodeInspector public extension NSWorkspace { - static func activateThisApp(delay: TimeInterval = 0.3) { + static func activateThisApp(delay: TimeInterval = 0.10) { Task { @MainActor in try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) From 8abc25eeab52ee723e708f640d49344b8d27e870 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 22:38:26 +0800 Subject: [PATCH 099/116] Update UI --- .../SuggestionPanelContent/CodeBlockSuggestionPanelView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index 4611afa7..16a616b5 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -226,6 +226,7 @@ struct CodeBlockSuggestionPanelView: View { dimmedCharacterCount: dimmedCharacterCount ) .frame(maxWidth: .infinity) + .padding(.vertical, 4) .background({ () -> Color in if syncHighlightTheme { if colorScheme == .light, From 20b609239fff54224c30b8a0f039601bc874c222 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 8 Sep 2024 22:39:34 +0800 Subject: [PATCH 100/116] Fix that searching for tabs in Xcode cause Xcode to have performance issue --- Tool/Sources/AXExtension/AXUIElement.swift | 60 ++++++++++++++++- .../Apps/XcodeAppInstanceInspector.swift | 64 +++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 28a22227..008dec6e 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -21,7 +21,7 @@ public extension AXUIElement { var value: String { (try? copyValue(key: kAXValueAttribute)) ?? "" } - + var intValue: Int? { (try? copyValue(key: kAXValueAttribute)) } @@ -134,7 +134,7 @@ public extension AXUIElement { var isFullScreen: Bool { (try? copyValue(key: "AXFullScreen")) ?? false } - + var isFrontmost: Bool { (try? copyValue(key: kAXFrontmostAttribute)) ?? false } @@ -191,6 +191,16 @@ public extension AXUIElement { return nil } + /// Get children that match the requirement + /// + /// - important: If the element has a lot of descendant nodes, it will heavily affect the + /// **performance of Xcode**. Please make use ``AXUIElement\traverse(_:)`` instead. + @available( + *, + deprecated, + renamed: "traverse(_:)", + message: "Please make use ``AXUIElement\traverse(_:)`` instead." + ) func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] { var all = [AXUIElement]() for child in children { @@ -233,6 +243,52 @@ public extension AXUIElement { } } +public extension AXUIElement { + enum SearchNextStep { + case skipDescendants + case skipSiblings + case skipDescendantsAndSiblings + case continueSearching + case stopSearching + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse(_ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep) { + func _traverse( + element: AXUIElement, + level: Int, + handle: (AXUIElement, Int) -> SearchNextStep + ) -> SearchNextStep { + let nextStep = handle(element, level) + switch nextStep { + case .stopSearching: return .stopSearching + case .skipDescendants: return .continueSearching + case .skipDescendantsAndSiblings: return .skipSiblings + case .continueSearching, .skipSiblings: + for child in element.children { + switch _traverse(element: child, level: level + 1, handle: handle) { + case .skipSiblings, .skipDescendantsAndSiblings: + break + case .stopSearching: + return .stopSearching + case .continueSearching, .skipDescendants: + continue + } + } + + return nextStep + } + } + + _ = _traverse(element: self, level: 0, handle: handle) + } +} + // MARK: - Helper public extension AXUIElement { diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index a9a21c9f..76ee96a6 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -353,16 +353,21 @@ extension XcodeAppInstanceInspector { for window in windows { let workspaceIdentifier = workspaceIdentifier(window) + var traverseCount = 0 let tabs = { guard let editArea = window.firstChild(where: { $0.description == "editor area" }) else { return Set() } var allTabs = Set() - let tabBars = editArea.children { $0.description == "tab bar" } + let tabBars = editArea.tabBars for tabBar in tabBars { - let tabs = tabBar.children { $0.roleDescription == "tab" } - for tab in tabs { - allTabs.insert(tab.title) + tabBar.traverse { element, _ in + traverseCount += 1 + if element.roleDescription == "tab" { + allTabs.insert(element.title) + return .skipDescendants + } + return .continueSearching } } return allTabs @@ -416,3 +421,54 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool { return matchXcode16CompletionPanel } +public extension AXUIElement { + var tabBars: [AXUIElement] { + // Searching by traversing with AXUIElement is (Xcode) resource consuming, we should skip + // as much as possible! + + guard let editArea: AXUIElement = { + if description == "editor area" { return self } + return firstChild(where: { $0.description == "editor area" }) + }() else { return [] } + + var tabBars = [AXUIElement]() + editArea.traverse { element, _ in + let description = element.description + if description == "Tab Bar" { + element.traverse { element, _ in + if element.description == "tab bar" { + tabBars.append(element) + return .stopSearching + } + return .continueSearching + } + + return .skipDescendantsAndSiblings + } + + if element.identifier == "editor context" { + return .skipDescendantsAndSiblings + } + + if element.isSourceEditor { + return .skipDescendantsAndSiblings + } + + if description == "Code Coverage Ribbon" { + return .skipDescendants + } + + if description == "Debug Area" { + return .skipDescendants + } + + if description == "debug bar" { + return .skipDescendants + } + + return .continueSearching + } + + return tabBars + } +} From dbdd9b02aea9025abcbe6cc8af74c4984885608f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 9 Sep 2024 01:03:16 +0800 Subject: [PATCH 101/116] Fix unit tests --- Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 46d355a3..da9cd557 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -131,7 +131,8 @@ private func runService( let memory = AutoManagedChatGPTMemory( systemPrompt: systemPrompt, configuration: configuration, - functionProvider: NoChatGPTFunctionProvider() + functionProvider: NoChatGPTFunctionProvider(), + maxNumberOfMessages: maxNumberOfMessages ) for message in messages { @@ -139,7 +140,6 @@ private func runService( } let messages = await memory.generateSendingHistory( - maxNumberOfMessages: maxNumberOfMessages, strategy: MockStrategy() ) From 1421c52f2a07f48c020585399a9afb8d22dc3d6f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 9 Sep 2024 01:40:30 +0800 Subject: [PATCH 102/116] Fix option+command+enter not working --- .../PromptToCodePanelView.swift | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 3e1aab22..cf414d4a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -304,28 +304,11 @@ extension PromptToCodePanelView { .padding(.trailing, 4) } .buttonStyle(TheButtonStyle()) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) - .background { - Button(action: { - switch ( - modifierFlags.contains(.option), - defaultModeIsContinuous - ) { - case (true, true): - store.send(.acceptAndContinueButtonTapped) - case (false, true): - store.send(.acceptButtonTapped) - case (true, false): - store.send(.acceptButtonTapped) - case (false, false): - store.send(.acceptAndContinueButtonTapped) - } - }) { - EmptyView() - } - .buttonStyle(.plain) - .keyboardShortcut(KeyEquivalent.return, modifiers: [.command, .option]) - } + .keyboardShortcut( + KeyEquivalent.return, + modifiers: modifierFlags + .contains(.option) ? [.command, .option] : [.command] + ) Divider() @@ -465,7 +448,7 @@ extension PromptToCodePanelView { DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) CodeContent( store: store, - language: language, + language: language, isGenerating: isGenerating, codeForegroundColor: codeForegroundColor ) From c9e5e97880145c262796161855b06286e217637a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 9 Sep 2024 01:40:59 +0800 Subject: [PATCH 103/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 57eac2d5..f0a8a2bd 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 407 +APP_BUILD = 408 From 35f377fd1b1ba060319ef7136bafbf622ab02394 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 9 Sep 2024 15:20:16 +0800 Subject: [PATCH 104/116] Comment out unsupported test case --- .../AcceptSuggestionTests.swift | 97 ++++++++++--------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift index 3a2b9499..f8f8b909 100644 --- a/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift +++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -1187,54 +1187,55 @@ final class AcceptSuggestionTests: XCTestCase { "3": .init(start: .init(line: 17, character: 0), end: .init(line: 20, character: 1)), ]) } - - func test_accepting_multiple_same_line_suggestions_at_a_time() async throws { - let content = "let foo = 1\n" - let text1 = "berry" - let suggestion1 = CodeSuggestion( - id: "1", - text: text1, - position: .init(line: 0, character: 4), - range: .init( - start: .init(line: 0, character: 4), - end: .init(line: 0, character: 7) - ), - replacingLines: [content] - ) - - let text2 = """ - 200 - """ - let suggestion2 = CodeSuggestion( - id: "2", - text: text2, - position: .init(line: 0, character: 10), - range: .init( - start: .init(line: 0, character: 10), - end: .init(line: 0, character: 11) - ), - replacingLines: [content] - ) - - var extraInfo = SuggestionInjector.ExtraInfo() - var lines = content.breakIntoEditorStyleLines() - var cursor = CursorPosition(line: 0, character: 0) - SuggestionInjector().acceptSuggestions( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursor, - completions: [suggestion1, suggestion2], - extraInfo: &extraInfo - ) - XCTAssertTrue(extraInfo.didChangeContent) - XCTAssertTrue(extraInfo.didChangeCursorPosition) - XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) - XCTAssertEqual(cursor, .init(line: 0, character: 15)) - XCTAssertEqual(lines.joined(separator: ""), "let berry = 200\n") - XCTAssertEqual(extraInfo.modificationRanges, [ - "1": .init(start: .init(line: 0, character: 4), end: .init(line: 0, character: 9)), - "2": .init(start: .init(line: 0, character: 12), end: .init(line: 0, character: 15)), - ]) - } + +// Not supported yet +// func test_accepting_multiple_same_line_suggestions_at_a_time() async throws { +// let content = "let foo = 1\n" +// let text1 = "berry" +// let suggestion1 = CodeSuggestion( +// id: "1", +// text: text1, +// position: .init(line: 0, character: 4), +// range: .init( +// start: .init(line: 0, character: 4), +// end: .init(line: 0, character: 7) +// ), +// replacingLines: [content] +// ) +// +// let text2 = """ +// 200 +// """ +// let suggestion2 = CodeSuggestion( +// id: "2", +// text: text2, +// position: .init(line: 0, character: 10), +// range: .init( +// start: .init(line: 0, character: 10), +// end: .init(line: 0, character: 11) +// ), +// replacingLines: [content] +// ) +// +// var extraInfo = SuggestionInjector.ExtraInfo() +// var lines = content.breakIntoEditorStyleLines() +// var cursor = CursorPosition(line: 0, character: 0) +// SuggestionInjector().acceptSuggestions( +// intoContentWithoutSuggestion: &lines, +// cursorPosition: &cursor, +// completions: [suggestion1, suggestion2], +// extraInfo: &extraInfo +// ) +// XCTAssertTrue(extraInfo.didChangeContent) +// XCTAssertTrue(extraInfo.didChangeCursorPosition) +// XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) +// XCTAssertEqual(cursor, .init(line: 0, character: 15)) +// XCTAssertEqual(lines.joined(separator: ""), "let berry = 200\n") +// XCTAssertEqual(extraInfo.modificationRanges, [ +// "1": .init(start: .init(line: 0, character: 4), end: .init(line: 0, character: 9)), +// "2": .init(start: .init(line: 0, character: 12), end: .init(line: 0, character: 15)), +// ]) +// } } extension String { From df4d976c578b0f912f867dfd859825c71128cefd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 9 Sep 2024 17:47:14 +0800 Subject: [PATCH 105/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index f0a8a2bd..7c893e4d 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 408 +APP_BUILD = 409 From aae7a56c68b8463c3f3f3beb1a6b4c0bd304ea32 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 11 Sep 2024 18:11:04 +0800 Subject: [PATCH 106/116] Add buttons to toast --- .../ToastPanelView.swift | 86 ++++++++++++++++--- .../WidgetWindowsController.swift | 3 +- Tool/Sources/Toast/Toast.swift | 50 +++++++++-- 3 files changed, 118 insertions(+), 21 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift index 5cd6ba23..c7aca342 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -12,34 +12,92 @@ struct ToastPanelView: View { VStack(spacing: 4) { if !store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } ForEach(store.toast.messages) { message in - message.content - .foregroundColor(.white) - .padding(8) - .frame(maxWidth: .infinity) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) + HStack { + message.content + .foregroundColor(.white) + .textSelection(.enabled) + + + if !message.buttons.isEmpty { + HStack { + ForEach( + Array(message.buttons.enumerated()), + id: \.offset + ) { _, button in + Button(action: button.action) { + button.label + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + RoundedRectangle(cornerRadius: 4) + .stroke(Color.white, lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .allowsHitTesting(true) + } } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.black.opacity(0.1), lineWidth: 1) } + } + .padding(8) + .frame(maxWidth: .infinity) + .background({ + switch message.type { + case .info: return Color.accentColor + case .error: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + } + }() as Color, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.black.opacity(0.1), lineWidth: 1) + } } if store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } } .colorScheme(store.colorScheme) .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) } } } +#Preview { + ToastPanelView(store: .init(initialState: .init( + toast: .init(messages: [ + ToastController.Message( + id: UUID(), + type: .info, + content: Text("Info message"), + buttons: [ + .init(label: Text("Dismiss"), action: {}), + .init(label: Text("More info"), action: {}), + ] + ), + ToastController.Message( + id: UUID(), + type: .error, + content: Text("Error message"), + buttons: [.init(label: Text("Dismiss"), action: {})] + ), + ToastController.Message( + id: UUID(), + type: .warning, + content: Text("Warning message"), + buttons: [.init(label: Text("Dismiss"), action: {})] + ), + ]) + ), reducer: { + ToastPanel() + })) +} + diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 00a635ff..b9f0853a 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -793,7 +793,7 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = true + it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(0) it.hasShadow = false @@ -804,7 +804,6 @@ public final class WidgetWindows { )) ) it.setIsVisible(true) - it.ignoresMouseEvents = true it.canBecomeKeyChecker = { false } return it }() diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift index 2abcca9a..3202ee49 100644 --- a/Tool/Sources/Toast/Toast.swift +++ b/Tool/Sources/Toast/Toast.swift @@ -46,15 +46,36 @@ public extension DependencyValues { public class ToastController: ObservableObject { public struct Message: Identifiable, Equatable { + public struct MessageButton: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.label == rhs.label + } + + public var label: Text + public var action: () -> Void + public init(label: Text, action: @escaping () -> Void) { + self.label = label + self.action = action + } + } + public var namespace: String? public var id: UUID public var type: ToastType public var content: Text - public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) { + public var buttons: [MessageButton] + public init( + id: UUID, + type: ToastType, + namespace: String? = nil, + content: Text, + buttons: [MessageButton] = [] + ) { self.namespace = namespace self.id = id self.type = type self.content = content + self.buttons = buttons } } @@ -64,16 +85,35 @@ public class ToastController: ObservableObject { self.messages = messages } - public func toast(content: String, type: ToastType, namespace: String? = nil) { + public func toast( + content: String, + type: ToastType, + namespace: String? = nil, + buttons: [Message.MessageButton] = [], + duration: TimeInterval = 4 + ) { let id = UUID() - let message = Message(id: id, type: type, namespace: namespace, content: Text(content)) + let message = Message( + id: id, + type: type, + namespace: namespace, + content: Text(content), + buttons: buttons.map { b in + Message.MessageButton(label: b.label, action: { [weak self] in + b.action() + withAnimation(.easeInOut(duration: 0.2)) { + self?.messages.removeAll { $0.id == id } + } + }) + } + ) Task { @MainActor in withAnimation(.easeInOut(duration: 0.2)) { messages.append(message) messages = messages.suffix(3) } - try await Task.sleep(nanoseconds: 4_000_000_000) + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) withAnimation(.easeInOut(duration: 0.2)) { messages.removeAll { $0.id == id } } @@ -84,7 +124,7 @@ public class ToastController: ObservableObject { @Reducer public struct Toast { public typealias Message = ToastController.Message - + @ObservableState public struct State: Equatable { var isObservingToastController = false From 5265dc4608fdadfb2d0a29de03f6511810cfe3df Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 12 Sep 2024 22:23:45 +0800 Subject: [PATCH 107/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 7c893e4d..fe8122ce 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 409 +APP_BUILD = 410 From e699d45ebdfb7e00d801e549cab7903142b8ba21 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 02:51:06 +0800 Subject: [PATCH 108/116] Fix previous suggestion button dismissing the suggestion --- .../CodeBlockSuggestionPanelView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index 16a616b5..cef8a0ad 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -62,7 +62,6 @@ struct CodeBlockSuggestionPanelView: View { Button(action: { Task { await commandHandler.presentPreviousSuggestion() - NSWorkspace.activatePreviousActiveXcode() } }) { Image(systemName: "chevron.left") @@ -82,7 +81,10 @@ struct CodeBlockSuggestionPanelView: View { Spacer() Button(action: { - Task { await commandHandler.dismissSuggestion() } + Task { + await commandHandler.dismissSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } }) { Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) }.buttonStyle(.plain) @@ -105,7 +107,7 @@ struct CodeBlockSuggestionPanelView: View { Text("Accept") }.buttonStyle(CommandButtonStyle(color: .accentColor)) } - .padding() + .padding(6) .foregroundColor(.secondary) .background(.regularMaterial) } From 741fe16da9881a12569f90d7eb31aeb09130c2fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 02:51:14 +0800 Subject: [PATCH 109/116] Update readme --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 720d0a92..312e65ff 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,9 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Limitations](#limitations) - [License](#license) -For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions). - For development instruction, check [Development.md](DEVELOPMENT.md). -For more information, check the [wiki](https://github.com/intitni/CopilotForXcode/wiki) +For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wiki). ## Prerequisites From 284ed9e79849c2f3abd4bfe795965989b2003e0b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 09:30:35 +0800 Subject: [PATCH 110/116] Add new openai models --- .../Preferences/Types/ChatGPTModel.swift | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index c9d24b1b..91c71196 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -14,13 +14,15 @@ public enum ChatGPTModel: String { case gpt4VisionPreview = "gpt-4-vision-preview" case gpt4TurboPreview = "gpt-4-turbo-preview" case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09" - case gpt35Turbo0613 = "gpt-3.5-turbo-0613" case gpt35Turbo1106 = "gpt-3.5-turbo-1106" case gpt35Turbo0125 = "gpt-3.5-turbo-0125" - case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613" case gpt432k0314 = "gpt-4-32k-0314" case gpt432k0613 = "gpt-4-32k-0613" case gpt40125 = "gpt-4-0125-preview" + case o1Preview = "o1-preview" + case o1Preview20240912 = "o1-preview-2024-09-12" + case o1Mini = "o1-mini" + case o1Mini20240912 = "o1-mini-2024-09-12" } public extension ChatGPTModel { @@ -36,42 +38,43 @@ public extension ChatGPTModel { return 32768 case .gpt35Turbo: return 16385 - case .gpt35Turbo0613: - return 4096 case .gpt35Turbo1106: return 16385 case .gpt35Turbo0125: return 16385 case .gpt35Turbo16k: return 16385 - case .gpt35Turbo16k0613: - return 16385 case .gpt40613: return 8192 case .gpt432k0613: return 32768 case .gpt41106Preview: - return 128000 + return 128_000 case .gpt4VisionPreview: - return 128000 + return 128_000 case .gpt4TurboPreview: - return 128000 + return 128_000 case .gpt40125: - return 128000 + return 128_000 case .gpt4Turbo: - return 128000 + return 128_000 case .gpt4Turbo20240409: - return 128000 + return 128_000 case .gpt4o: - return 128000 + return 128_000 case .gpt4oMini: - return 128000 + return 128_000 + case .o1Preview, .o1Preview20240912: + return 128_000 + case .o1Mini, .o1Mini20240912: + return 128_000 } } - + var supportsImages: Bool { switch self { - case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o: + case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o, .gpt4oMini, .o1Preview, + .o1Preview20240912, .o1Mini, .o1Mini20240912: return true default: return false @@ -79,4 +82,3 @@ public extension ChatGPTModel { } } -extension ChatGPTModel: CaseIterable {} From 5774302e725e1c2ef5de7088b540bdec2e02b59d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 10:33:53 +0800 Subject: [PATCH 111/116] Fix modification diff --- .../PromptToCodePanelView.swift | 5 +-- .../SharedUIComponents/AsyncCodeBlock.swift | 35 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index cf414d4a..518b9968 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -619,7 +619,8 @@ extension PromptToCodePanelView { scenario: "promptToCode", font: codeFont.value.nsFont, droppingLeadingSpaces: hideCommonPrecedingSpaces, - proposedForegroundColor: codeForegroundColor + proposedForegroundColor: codeForegroundColor, + ignoreWholeLineChange: false ) .frame(maxWidth: .infinity) @@ -741,7 +742,7 @@ extension PromptToCodePanelView { .init( startLineIndex: 8, originalCode: "print(foo)", - modifiedCode: "print(bar)", + modifiedCode: "print(bar)\nprint(baz)", description: "", error: "Error", attachedRange: CursorRange( diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift index 5e36a852..8969ef6c 100644 --- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -26,6 +26,8 @@ public struct AsyncCodeBlock: View { // chat: hid let dimmedCharacterCount: DimmedCharacterCount /// Whether to drop common leading spaces of each line. let droppingLeadingSpaces: Bool + /// Whether to ignore whole line change in diff. + let ignoreWholeLineChangeInDiff: Bool public init( code: String, @@ -36,7 +38,8 @@ public struct AsyncCodeBlock: View { // chat: hid font: NSFont, droppingLeadingSpaces: Bool, proposedForegroundColor: Color?, - dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0) + dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0), + ignoreWholeLineChangeInDiff: Bool = true ) { self.code = code self.originalCode = originalCode @@ -47,6 +50,7 @@ public struct AsyncCodeBlock: View { // chat: hid self.proposedForegroundColor = proposedForegroundColor self.dimmedCharacterCount = dimmedCharacterCount self.droppingLeadingSpaces = droppingLeadingSpaces + self.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff } var foregroundColor: Color { @@ -89,6 +93,7 @@ public struct AsyncCodeBlock: View { // chat: hid .padding(.bottom, 4) .onAppear { storage.dimmedCharacterCount = dimmedCharacterCount + storage.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff storage.highlightStorage.highlight(debounce: false, for: self) storage.diffStorage.diff(for: self) } @@ -119,6 +124,9 @@ public struct AsyncCodeBlock: View { // chat: hid .onChange(of: dimmedCharacterCount) { value in storage.dimmedCharacterCount = value } + .onChange(of: ignoreWholeLineChangeInDiff) { value in + storage.ignoreWholeLineChangeInDiff = value + } } } } @@ -146,6 +154,7 @@ extension AsyncCodeBlock { var dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0) let diffStorage = DiffStorage() let highlightStorage = HighlightStorage() + var ignoreWholeLineChangeInDiff: Bool = true var code: String? { get { highlightStorage.code } @@ -175,6 +184,7 @@ extension AsyncCodeBlock { Self.presentDiff( highlightedCode, commonPrecedingSpaceCount: commonPrecedingSpaceCount, + ignoreWholeLineChange: ignoreWholeLineChangeInDiff, diffResult: diffResult ) } @@ -251,6 +261,7 @@ extension AsyncCodeBlock { static func presentDiff( _ highlightedCode: [NSMutableAttributedString], commonPrecedingSpaceCount: Int, + ignoreWholeLineChange: Bool, diffResult: CodeDiff.SnippetDiff ) { let originalCodeIsSingleLine = diffResult.sections.count == 1 @@ -266,8 +277,9 @@ extension AsyncCodeBlock { change.element.count - commonPrecedingSpaceCount == mutableString.string.count { - // ignore the whole line change - continue + if ignoreWholeLineChange { + continue + } } let offset = change.offset - commonPrecedingSpaceCount @@ -488,6 +500,22 @@ extension AsyncCodeBlock { .frame(width: 400, height: 100) } +#Preview("Multiple Line Suggestion Including Whole Line Change in Diff") { + AsyncCodeBlock( + code: "// comment\n let foo = Bar()\n print(bar)\n print(foo)", + originalCode: " let foo = Bar()\n", + language: "swift", + startLineIndex: 10, + scenario: "", + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: .primary, + dimmedCharacterCount: .init(prefix: 11, suffix: 0), + ignoreWholeLineChangeInDiff: false + ) + .frame(width: 400, height: 100) +} + #Preview("Updating Content") { struct UpdateContent: View { @State var index = 0 @@ -524,3 +552,4 @@ extension AsyncCodeBlock { return UpdateContent() .frame(width: 400, height: 200) } + From dddd2c34c81b7163e9c6adc331e0bce0bc69981f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 10:34:03 +0800 Subject: [PATCH 112/116] Pin tca version --- Core/Package.swift | 2 +- Tool/Package.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index fb5e8e03..283e09b2 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -44,7 +44,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "1.10.4" + exact: "1.10.4" ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts diff --git a/Tool/Package.swift b/Tool/Package.swift index 874569fe..06730e8b 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -65,7 +65,7 @@ let package = Package( .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - from: "1.10.4" + exact: "1.10.4" ), .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), From de9cef26cff5779a277ff20fd62e086e2ee4f354 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 10:34:13 +0800 Subject: [PATCH 113/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index fe8122ce..15d6bdeb 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 410 +APP_BUILD = 411 From 29cabbad39cd5609e83567c9adb699ed2de91ff8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 11:42:40 +0800 Subject: [PATCH 114/116] Fix --- Tool/Sources/Preferences/Types/ChatGPTModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 91c71196..8ac04faf 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -1,6 +1,6 @@ import Foundation -public enum ChatGPTModel: String { +public enum ChatGPTModel: String, CaseIterable { case gpt35Turbo = "gpt-3.5-turbo" case gpt35Turbo16k = "gpt-3.5-turbo-16k" case gpt4o = "gpt-4o" From 3319441765a20bc25f558668ae06cd9f408fdd06 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 11:43:03 +0800 Subject: [PATCH 115/116] Make max height of modification window taller --- .../SuggestionWidget/SharedPanelView.swift | 51 ++++++++++--------- .../PromptToCodePanelView.swift | 2 +- .../WidgetPositionStrategy.swift | 25 ++++++++- .../WidgetWindowsController.swift | 3 +- 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index f7f2f604..1c1c1a91 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -29,35 +29,36 @@ struct SharedPanelView: View { } var body: some View { - WithPerceptionTracking { - VStack(spacing: 0) { - if !store.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) - } - - DynamicContent(store: store) + GeometryReader { geometry in + WithPerceptionTracking { + VStack(spacing: 0) { + if !store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) - .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting(store.isPanelDisplayed) - .frame(maxWidth: .infinity) + DynamicContent(store: store) + .frame(maxWidth: .infinity, maxHeight: geometry.size.height) + .fixedSize(horizontal: false, vertical: true) + .allowsHitTesting(store.isPanelDisplayed) + .layoutPriority(1) - if store.alignTopToAnchor { - Spacer() - .frame(minHeight: 0, maxHeight: .infinity) - .allowsHitTesting(false) + if store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed + ) + .frame(maxWidth: Style.panelWidth, maxHeight: .infinity) } - .preferredColorScheme(store.colorScheme) - .opacity(store.opacity) - .animation( - featureFlag: \.animationBCrashSuggestion, - .easeInOut(duration: 0.2), - value: store.isPanelDisplayed - ) - .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 518b9968..79809b72 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -620,7 +620,7 @@ extension PromptToCodePanelView { font: codeFont.value.nsFont, droppingLeadingSpaces: hideCommonPrecedingSpaces, proposedForegroundColor: codeForegroundColor, - ignoreWholeLineChange: false + ignoreWholeLineChangeInDiff: false ) .frame(maxWidth: .infinity) diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 41f6c17c..b7ceb487 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -9,6 +9,7 @@ public struct WidgetLocation: Equatable { var widgetFrame: CGRect var tabFrame: CGRect + var sharedPanelLocation: PanelLocation var defaultPanelLocation: PanelLocation var suggestionPanelLocation: PanelLocation? } @@ -70,7 +71,7 @@ enum UpdateLocationStrategy { .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan), editorFrameExpendedSize: CGSize = .zero ) -> WidgetLocation { - return HorizontalMovable().framesForWindows( + var frames = HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, alignPanelTopToAnchor: false, editorFrame: editorFrame, @@ -80,6 +81,16 @@ enum UpdateLocationStrategy { hideCircularWidget: hideCircularWidget, editorFrameExpendedSize: editorFrameExpendedSize ) + + frames.sharedPanelLocation.frame.size.height = max( + frames.defaultPanelLocation.frame.height, + editorFrame.height - Style.widgetHeight + ) + frames.defaultPanelLocation.frame.size.height = max( + frames.defaultPanelLocation.frame.height, + (editorFrame.height - Style.widgetHeight) / 2 + ) + return frames } } @@ -155,6 +166,10 @@ enum UpdateLocationStrategy { return .init( widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor @@ -214,6 +229,10 @@ enum UpdateLocationStrategy { return .init( widgetFrame: widgetFrameOnTheLeftSide, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor @@ -241,6 +260,10 @@ enum UpdateLocationStrategy { return .init( widgetFrame: widgetFrameOnTheRightSide, tabFrame: tabFrame, + sharedPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), defaultPanelLocation: .init( frame: panelFrame, alignPanelTop: alignPanelTopToAnchor diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index b9f0853a..304f6d76 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -335,6 +335,7 @@ extension WidgetWindowsController { return WidgetLocation( widgetFrame: .zero, tabFrame: .zero, + sharedPanelLocation: .init(frame: .zero, alignPanelTop: false), defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) ) } @@ -479,7 +480,7 @@ extension WidgetWindowsController { animate: animated ) windows.sharedPanelWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, + widgetLocation.sharedPanelLocation.frame, display: false, animate: animated ) From b9962163781fa97b65880910d925747160455bc9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 13 Sep 2024 14:03:01 +0800 Subject: [PATCH 116/116] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 15d6bdeb..6b4ae4b3 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.34.0 -APP_BUILD = 411 +APP_BUILD = 412