diff --git a/Core/Package.swift b/Core/Package.swift index 50a6e18e..6523b254 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -129,7 +129,6 @@ let package = Package( .product(name: "Workspace", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Environment", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), @@ -152,7 +151,6 @@ let package = Package( .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), - .product(name: "Environment", package: "Tool"), .product(name: "Preferences", package: "Tool"), ] ), @@ -206,7 +204,6 @@ let package = Package( dependencies: [ .product(name: "FocusedCodeFinder", package: "Tool"), .product(name: "SuggestionModel", package: "Tool"), - .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -234,7 +231,6 @@ let package = Package( .product(name: "ChatContextCollector", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Environment", package: "Tool"), .product(name: "Parsing", package: "swift-parsing"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Preferences", package: "Tool"), @@ -246,7 +242,6 @@ let package = Package( .target( name: "ChatPlugin", dependencies: [ - .product(name: "Environment", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] @@ -277,7 +272,6 @@ let package = Package( .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "SharedUIComponents", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Environment", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift index 803f2806..6e95f29d 100644 --- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift @@ -1,4 +1,3 @@ -import Environment import Foundation import OpenAIService import Terminal diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index 9022c788..6e2d9d1e 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift @@ -1,7 +1,7 @@ -import Environment import Foundation import OpenAIService import Terminal +import XcodeInspector public actor TerminalChatPlugin: ChatPlugin { public static var command: String { "run" } @@ -34,13 +34,16 @@ public actor TerminalChatPlugin: ChatPlugin { } do { - let fileURL = try await Environment.fetchCurrentFileURL() - let projectURL = try await { - if let url = try await Environment.fetchCurrentProjectRootURLFromXcode() { - return url - } - return try await Environment.guessProjectRootURLForFile(fileURL) - }() + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL + + var environment = [String: String]() + if let fileURL { + environment["FILE_PATH"] = fileURL.path + } + if let projectURL { + environment["PROJECT_ROOT"] = projectURL.path + } await chatGPTService.memory.mutateHistory { history in history.append( @@ -59,11 +62,8 @@ public actor TerminalChatPlugin: ChatPlugin { let output = terminal.streamCommand( shell, arguments: ["-i", "-l", "-c", content], - currentDirectoryPath: projectURL.path, - environment: [ - "PROJECT_ROOT": projectURL.path, - "FILE_PATH": fileURL.path, - ] + currentDirectoryURL: projectURL, + environment: environment ) for try await content in output { diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift index 913d088f..2bfa3846 100644 --- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift +++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift @@ -1,5 +1,4 @@ import ChatPlugin -import Environment import Foundation import OpenAIService diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift index e4c60aca..99cf6028 100644 --- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift +++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift @@ -1,5 +1,4 @@ import ChatPlugin -import Environment import Foundation import OpenAIService diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift index 0fc3cc06..c6a9bddf 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -1,5 +1,4 @@ import ChatPlugin -import Environment import Foundation import OpenAIService import Parsing @@ -77,7 +76,7 @@ public actor ShortcutChatPlugin: ChatPlugin { _ = try await terminal.runCommand( shell, arguments: ["-i", "-l", "-c", command], - currentDirectoryPath: "/", + currentDirectoryURL: nil, environment: [:] ) diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift index eeeddc0d..5616f072 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift @@ -1,5 +1,4 @@ import ChatPlugin -import Environment import Foundation import OpenAIService import Parsing @@ -77,7 +76,7 @@ public actor ShortcutInputChatPlugin: ChatPlugin { _ = try await terminal.runCommand( shell, arguments: ["-i", "-l", "-c", command], - currentDirectoryPath: "/", + currentDirectoryURL: nil, environment: [:] ) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 880704c4..212b8313 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -17,6 +17,9 @@ struct CustomCommandFeature: ReducerProtocol { case editCommand(CustomCommand) case editCustomCommand(EditCustomCommand.Action) case deleteCommand(CustomCommand) + case exportCommand(CustomCommand) + case importCommand(at: URL) + case importCommandClicked } @Dependency(\.toast) var toast @@ -49,6 +52,75 @@ struct CustomCommandFeature: ReducerProtocol { return .none case .editCustomCommand: return .none + case let .exportCommand(command): + return .run { _ in + do { + let data = try JSONEncoder().encode(command) + let filename = "CustomCommand-\(command.name).json" + + let url = await withCheckedContinuation { continuation in + Task { @MainActor in + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.nameFieldStringValue = filename + let result = await panel.begin() + switch result { + case .OK: + continuation.resume(returning: panel.url) + default: + continuation.resume(returning: nil) + } + } + } + + if let url { + try data.write(to: url) + toast("Saved!", .info) + } + + } catch { + toast(error.localizedDescription, .error) + } + } + + case let .importCommand(url): + if !isFeatureAvailable(\.unlimitedCustomCommands), + settings.customCommands.count >= 10 + { + toast("Upgrade to Plus to add more commands", .info) + return .none + } + + do { + let data = try Data(contentsOf: url) + var command = try JSONDecoder().decode(CustomCommand.self, from: data) + command.commandId = UUID().uuidString + settings.customCommands.append(command) + toast("Imported custom command \(command.name)!", .info) + } catch { + toast("Failed to import command: \(error.localizedDescription)", .error) + } + return .none + + case .importCommandClicked: + return .run { send in + let url = await withCheckedContinuation { continuation in + Task { @MainActor in + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + let result = await panel.begin() + if result == .OK { + continuation.resume(returning: panel.url) + } else { + continuation.resume(returning: nil) + } + } + } + + if let url { + await send(.importCommand(at: url)) + } + } } }.ifLet(\.editCustomCommand, action: /Action.editCustomCommand) { EditCustomCommand(settings: settings) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index 66547d6e..22594715 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -4,6 +4,7 @@ import PlusFeatureFlag import Preferences import SharedUIComponents import SwiftUI +import Toast extension List { @ViewBuilder @@ -51,7 +52,7 @@ struct CustomCommandView: View { @ViewBuilder var leftPane: some View { List { - ForEach(settings.customCommands, id: \.name) { command in + ForEach(settings.customCommands, id: \.commandId) { command in CommandButton(store: store, command: command) } .onMove(perform: { indices, newOffset in @@ -92,6 +93,33 @@ struct CustomCommandView: View { } .buttonStyle(.plain) .padding() + .contextMenu { + Button("Import") { + store.send(.importCommandClicked) + } + } + } + .onDrop(of: [.json], delegate: FileDropDelegate(store: store, toast: toast)) + } + + struct FileDropDelegate: DropDelegate { + let store: StoreOf + let toast: (String, ToastType) -> Void + func performDrop(info: DropInfo) -> Bool { + let jsonFiles = info.itemProviders(for: [.json]) + for file in jsonFiles { + file.loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in + Task { @MainActor in + if let url { + store.send(.importCommand(at: url)) + } else if let error { + toast(error.localizedDescription, .error) + } + } + } + } + + return !jsonFiles.isEmpty } } @@ -143,6 +171,10 @@ struct CustomCommandView: View { Button("Remove") { store.send(.deleteCommand(command)) } + + Button("Export") { + store.send(.exportCommand(command)) + } } } } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index 66a1d91b..cdb6f81e 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -54,6 +54,8 @@ struct SuggestionSettingsView: View { var suggestionDisplayCompactMode @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @AppStorage(\.dismissSuggestionWithEsc) + var dismissSuggestionWithEsc @AppStorage(\.isSuggestionSenseEnabled) var isSuggestionSenseEnabled @@ -185,6 +187,10 @@ struct SuggestionSettingsView: View { Text("Accept Suggestion with Tab") } } + + Toggle(isOn: $settings.dismissSuggestionWithEsc) { + Text("Dismiss Suggestion with ESC") + } #endif HStack { diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index 9f402411..4f17fc81 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -5,7 +5,6 @@ import ChatGPTChatTab import ChatTab import ComposableArchitecture import Dependencies -import Environment import Preferences import SuggestionModel import SuggestionWidget diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index b51001f8..6233a6e5 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -50,6 +50,13 @@ extension WidgetDataSource: SuggestionWidgetDataSource { await handler.acceptSuggestion() NSWorkspace.activatePreviousActiveXcode() } + }, + onDismissSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.dismissSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } } ) } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 2b529c2b..d702fa87 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -3,7 +3,6 @@ import AppKit import AsyncAlgorithms import AXExtension import AXNotificationStream -import Environment import Foundation import Logger import Preferences @@ -21,7 +20,7 @@ public actor RealtimeSuggestionController { private var sourceEditor: SourceEditor? init() {} - + deinit { task?.cancel() inflightPrefetchTask?.cancel() @@ -34,7 +33,7 @@ public actor RealtimeSuggestionController { func start() { Task { await observeXcodeChange() } } - + private func observeXcodeChange() { task?.cancel() task = Task { [weak self] in @@ -90,7 +89,7 @@ public actor RealtimeSuggestionController { Task { // Notify suggestion service for open file. try await Task.sleep(nanoseconds: 500_000_000) - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } _ = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) } @@ -108,7 +107,7 @@ public actor RealtimeSuggestionController { ) editorObservationTask = Task { [weak self] in - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } if let sourceEditor = await self?.sourceEditor { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, @@ -141,7 +140,7 @@ public actor RealtimeSuggestionController { Task { @WorkspaceActor in // Get cache ready for real-time suggestions. guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -150,7 +149,8 @@ public actor RealtimeSuggestionController { // avoid the command get called twice filespace.codeMetadata.uti = "" do { - try await Environment.triggerAction("Real-time Suggestions") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Real-time Suggestions") } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil @@ -170,7 +170,7 @@ public actor RealtimeSuggestionController { else { return } if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), - let fileURL = try? await Environment.fetchCurrentFileURL(), + let fileURL = XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) { @@ -209,7 +209,7 @@ public actor RealtimeSuggestionController { } func notifyEditingFileChange(editor: AXUIElement) async { - guard let fileURL = try? await Environment.fetchCurrentFileURL(), + guard let fileURL = XcodeInspector.shared.activeDocumentURL, let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 7178055c..08d86cae 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -47,13 +47,14 @@ public final class Service { globalShortcutManager = .init(guiController: guiController) #if canImport(ProService) - proService = withDependencies { dependencyValues in - dependencyValues.proServiceAcceptSuggestion = { + proService = ProService( + acceptSuggestion: { Task { await PseudoCommandHandler().acceptSuggestion() } + }, + dismissSuggestion: { + Task { await PseudoCommandHandler().dismissSuggestion() } } - } operation: { - ProService() - } + ) #endif scheduledCleaner.service = self @@ -92,7 +93,7 @@ public extension Service { reply(nil, error) return } - + reply(nil, XPCRequestNotHandledError()) } } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f359ebff..f0a1fd47 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -1,6 +1,5 @@ import ActiveApplicationMonitor import AppKit -import Environment import Preferences import SuggestionInjector import SuggestionModel @@ -145,7 +144,8 @@ struct PseudoCommandHandler { } }() else { do { - try await Environment.triggerAction(command.name) + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: command.name) } catch { let presenter = PresentInWindowSuggestionPresenter() presenter.presentError(error) @@ -167,7 +167,8 @@ struct PseudoCommandHandler { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } - try await Environment.triggerAction("Accept Prompt to Code") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Prompt to Code") } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode ?? ActiveApplicationMonitor.shared.latestXcode else { return } @@ -206,7 +207,8 @@ struct PseudoCommandHandler { if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { throw CancellationError() } - try await Environment.triggerAction("Accept Suggestion") + try await XcodeInspector.shared.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode ?? ActiveApplicationMonitor.shared.latestXcode else { return } @@ -239,6 +241,15 @@ struct PseudoCommandHandler { } } } + + func dismissSuggestion() async { + guard let documentURL = XcodeInspector.shared.activeDocumentURL else { return } + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } + + await filespace.reset() + PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) + } } extension PseudoCommandHandler { @@ -321,14 +332,14 @@ extension PseudoCommandHandler { return (content, split, [range], range.start) } - func getFileURL() async -> URL? { - try? await Environment.fetchCurrentFileURL() + func getFileURL() -> URL? { + XcodeInspector.shared.realtimeActiveDocumentURL } @WorkspaceActor func getFilespace() async -> Filespace? { guard - let fileURL = await getFileURL(), + let fileURL = getFileURL(), let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 03fdfd25..7702b8cc 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,6 +1,5 @@ import AppKit import ChatService -import Environment import Foundation import GitHubCopilotService import LanguageServerProtocol @@ -12,6 +11,7 @@ import SuggestionWidget import UserNotifications import Workspace import WorkspaceSuggestionService +import XcodeInspector import XPCShared struct WindowBaseCommandHandler: SuggestionCommandHandler { @@ -39,7 +39,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -82,7 +82,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { private func _presentNextSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectNextSuggestion(forFileAt: fileURL) @@ -109,7 +109,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { private func _presentPreviousSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) workspace.selectPreviousSuggestion(forFileAt: fileURL) @@ -136,7 +136,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { private func _rejectSuggestion(editor: EditorContent) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -149,7 +149,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -185,7 +185,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let injector = SuggestionInjector() var lines = editor.lines @@ -260,7 +260,7 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (_, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) filespace.codeMetadata.uti = editor.uti @@ -365,7 +365,7 @@ extension WindowBaseCommandHandler { ) async throws { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - let fileURL = try await Environment.fetchCurrentFileURL() + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { @@ -425,7 +425,7 @@ extension WindowBaseCommandHandler { usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, documentURL: fileURL, projectRootURL: workspace.projectRootURL, - allCode: editor.content, + allCode: editor.content, allLines: editor.lines, isContinuous: isContinuous, commandName: name, diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 3a8ed45f..ee078b23 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -1,5 +1,4 @@ import AppKit -import Environment import Foundation import GitHubCopilotService import LanguageServerProtocol @@ -206,3 +205,11 @@ public class XPCService: NSObject, XPCServiceProtocol { } } +struct NoAccessToAccessibilityAPIError: Error, LocalizedError { + var errorDescription: String? { + "Accessibility API permission is not granted. Please enable in System Settings.app." + } + + init() {} +} + diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 12e11915..cda1a21d 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -9,6 +9,7 @@ private let r: Double = 8 struct ChatWindowView: View { let store: StoreOf + let toggleVisibility: (Bool) -> Void struct OverallState: Equatable { var isPanelDisplayed: Bool @@ -28,7 +29,7 @@ struct ChatWindowView: View { } ) { viewStore in VStack(spacing: 0) { - ChatTitleBar(store: store) + Rectangle().fill(.regularMaterial).frame(height: 28) Divider() @@ -40,10 +41,12 @@ struct ChatWindowView: View { ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) .background(.regularMaterial) - .xcodeStyleFrame() - .opacity(viewStore.state.isPanelDisplayed ? 1 : 0) - .frame(minWidth: Style.panelWidth, minHeight: Style.panelHeight) + .onChange(of: viewStore.state.isPanelDisplayed) { isDisplayed in + toggleVisibility(isDisplayed) + } .preferredColorScheme(viewStore.state.colorScheme) } } @@ -55,10 +58,15 @@ struct ChatTitleBar: View { var body: some View { HStack(spacing: 6) { - TrafficLightButton( - isHovering: isHovering, - isActive: true, - color: Color(nsColor: .systemOrange), + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("w", modifiers: [.command]) + + Button( action: { store.send(.hideButtonClicked) } @@ -67,8 +75,11 @@ struct ChatTitleBar: View { .foregroundStyle(.black.opacity(0.5)) .font(Font.system(size: 8).weight(.heavy)) } + .opacity(0) .keyboardShortcut("m", modifiers: [.command]) + Spacer() + WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in TrafficLightButton( isHovering: isHovering, @@ -84,34 +95,9 @@ struct ChatTitleBar: View { .transformEffect(.init(translationX: 0, y: 0.5)) } } - - Button(action: { - store.send(.closeActiveTabClicked) - }) { - EmptyView() - } - .opacity(0) - .keyboardShortcut("w", modifiers: [.command]) - - Spacer() } .buttonStyle(.plain) - .overlay { - RoundedRectangle(cornerRadius: 2) - .fill(.tertiary) - .frame(width: 120, height: 4) - .background { - if isHovering { - RoundedRectangle(cornerRadius: 6) - .fill(.tertiary.opacity(0.3)) - .frame(width: 128, height: 12) - } - } - } - .padding(.horizontal, 6) - .padding(.top, 1) - .frame(maxWidth: .infinity) - .frame(height: Style.chatWindowTitleBarHeight) + .padding(.trailing, 8) .onHover(perform: { hovering in isHovering = hovering }) @@ -453,7 +439,7 @@ struct ChatWindowView_Previews: PreviewProvider { } static var previews: some View { - ChatWindowView(store: createStore()) + ChatWindowView(store: createStore(), toggleVisibility: { _ in }) .xcodeStyleFrame() .padding() .environment(\.chatTabPool, pool) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 84446c9d..b1742cfa 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -49,6 +49,7 @@ public struct ChatPanelFeature: ReducerProtocol { var colorScheme: ColorScheme = .light public internal(set) var isPanelDisplayed = false var chatPanelInASeparateWindow = false + var isFullScreen = false } public enum Action: Equatable { @@ -58,6 +59,8 @@ public struct ChatPanelFeature: ReducerProtocol { case toggleChatPanelDetachedButtonClicked case detachChatPanel case attachChatPanel + case enterFullScreen + case exitFullScreen case presentChatPanel(forceDetach: Bool) // Tabs @@ -81,12 +84,25 @@ public struct ChatPanelFeature: ReducerProtocol { @Dependency(\.activateThisApp) var activateExtensionService @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection + @MainActor func toggleFullScreen() { + let window = suggestionWidgetControllerDependency.windows + .chatPanelWindow + window?.toggleFullScreen(nil) + } + public var body: some ReducerProtocol { Reduce { state, action in switch action { case .hideButtonClicked: state.isPanelDisplayed = false + if state.isFullScreen { + return .run { _ in + await MainActor.run { toggleFullScreen() } + activatePreviouslyActiveXcode() + } + } + return .run { _ in activatePreviouslyActiveXcode() } @@ -102,6 +118,12 @@ public struct ChatPanelFeature: ReducerProtocol { return .none case .toggleChatPanelDetachedButtonClicked: + if state.isFullScreen, state.chatPanelInASeparateWindow { + return .run { send in + await send(.attachChatPanel) + } + } + state.chatPanelInASeparateWindow.toggle() return .none @@ -110,9 +132,27 @@ public struct ChatPanelFeature: ReducerProtocol { return .none case .attachChatPanel: + if state.isFullScreen { + return .run { send in + await MainActor.run { toggleFullScreen() } + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.attachChatPanel) + } + } + state.chatPanelInASeparateWindow = false return .none + case .enterFullScreen: + state.isFullScreen = true + return .run { send in + await send(.detachChatPanel) + } + + case .exitFullScreen: + state.isFullScreen = false + return .none + case let .presentChatPanel(forceDetach): if forceDetach { state.chatPanelInASeparateWindow = true @@ -227,7 +267,7 @@ public struct ChatPanelFeature: ReducerProtocol { state.chatTabGroup.tabInfo.remove(at: from) state.chatTabGroup.tabInfo.insert(tab, at: to) return .none - + case .focusActiveChatTab: let id = state.chatTabGroup.selectedTabInfo?.id guard let id else { return .none } @@ -239,7 +279,7 @@ public struct ChatPanelFeature: ReducerProtocol { return .run { send in await send(.closeTabButtonClicked(id: id)) } - + case .chatTab: return .none } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift index e3978d1d..abbf302a 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift @@ -1,6 +1,5 @@ import ActiveApplicationMonitor import ComposableArchitecture -import Environment import Preferences import SuggestionModel import SwiftUI diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index d689509a..ec43c49c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Environment import Foundation import PromptToCodeService import SuggestionModel diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift index 30d6a1c0..27602dac 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Environment import Preferences import SwiftUI diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift index c70c60a7..332caf9d 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Environment import Preferences import SwiftUI import Toast diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 8578ab67..86943827 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -3,7 +3,6 @@ import AppActivator import AsyncAlgorithms import AXNotificationStream import ComposableArchitecture -import Environment import Foundation import Preferences import SwiftUI @@ -582,9 +581,9 @@ public struct WidgetFeature: ReducerProtocol { windows.toastWindow.alphaValue = noFocus ? 0 : 1 if isChatPanelDetached { - windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 + windows.chatPanelWindow.isWindowHidden = !hasChat } else { - windows.chatPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.chatPanelWindow.isWindowHidden = noFocus } } else if let activeApp, activeApp.isExtensionService { let noFocus = { @@ -603,10 +602,10 @@ public struct WidgetFeature: ReducerProtocol { windows.widgetWindow.alphaValue = noFocus ? 0 : 1 windows.toastWindow.alphaValue = noFocus ? 0 : 1 if isChatPanelDetached { - windows.chatPanelWindow.alphaValue = hasChat ? 1 : 0 + windows.chatPanelWindow.isWindowHidden = !hasChat } else { - windows.chatPanelWindow.alphaValue = noFocus && !windows - .chatPanelWindow.isKeyWindow ? 0 : 1 + windows.chatPanelWindow.isWindowHidden = noFocus && !windows + .chatPanelWindow.isKeyWindow } } else { windows.sharedPanelWindow.alphaValue = 0 @@ -614,7 +613,7 @@ public struct WidgetFeature: ReducerProtocol { windows.widgetWindow.alphaValue = 0 windows.toastWindow.alphaValue = 0 if !isChatPanelDetached { - windows.chatPanelWindow.alphaValue = 0 + windows.chatPanelWindow.isWindowHidden = true } } } diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index c50e820a..af322303 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -24,7 +24,7 @@ public final class WidgetWindows { var widgetWindow: NSWindow! var sharedPanelWindow: NSWindow! var suggestionPanelWindow: NSWindow! - var chatPanelWindow: NSWindow! + var chatPanelWindow: ChatWindow! var toastWindow: NSWindow! nonisolated diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift index 61167584..afede0a2 100644 --- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -18,6 +18,7 @@ public final class CodeSuggestionProvider: ObservableObject, Equatable { public var onSelectNextSuggestionTapped: () -> Void public var onRejectSuggestionTapped: () -> Void public var onAcceptSuggestionTapped: () -> Void + public var onDismissSuggestionTapped: () -> Void public init( code: String = "", @@ -28,7 +29,8 @@ public final class CodeSuggestionProvider: ObservableObject, Equatable { onSelectPreviousSuggestionTapped: @escaping () -> Void = {}, onSelectNextSuggestionTapped: @escaping () -> Void = {}, onRejectSuggestionTapped: @escaping () -> Void = {}, - onAcceptSuggestionTapped: @escaping () -> Void = {} + onAcceptSuggestionTapped: @escaping () -> Void = {}, + onDismissSuggestionTapped: @escaping () -> Void = {} ) { self.code = code self.language = language @@ -39,11 +41,13 @@ public final class CodeSuggestionProvider: ObservableObject, Equatable { 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 5abeb81f..d9f42dde 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -1,5 +1,4 @@ import ComposableArchitecture -import Environment import Preferences import SwiftUI diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index d1e0e102..8aa87817 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -46,14 +46,14 @@ extension NSAppearance { } extension View { - func xcodeStyleFrame() -> some View { - clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + func xcodeStyleFrame(cornerRadius: Double = 8) -> some View { + clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.black.opacity(0.3), style: .init(lineWidth: 1)) ) .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) + RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) .padding(1) ) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 3013e23e..91e3c364 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -74,7 +74,7 @@ struct CodeBlockSuggestionPanel: View { Spacer() Button(action: { - suggestion.rejectSuggestion() + suggestion.dismissSuggestion() }) { Image(systemName: "xmark") }.buttonStyle(.plain) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 15312c32..102c2e64 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -5,7 +5,6 @@ import AXNotificationStream import ChatTab import Combine import ComposableArchitecture -import Environment import Preferences import SwiftUI import UserDefaultsObserver @@ -123,26 +122,52 @@ public final class SuggestionWidgetController: NSObject { private lazy var chatPanelWindow = { let it = ChatWindow( contentRect: .zero, - styleMask: [.resizable], + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false ) + it.minimizeWindow = { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } + it.titleVisibility = .hidden + it.addTitlebarAccessoryViewController({ + let controller = NSTitlebarAccessoryViewController() + let view = NSHostingView(rootView: ChatTitleBar(store: store.scope( + state: \.chatPanelState, + action: WidgetFeature.Action.chatPanel + ))) + controller.view = view + view.frame = .init(x: 0, y: 0, width: 100, height: 40) + controller.layoutAttribute = .right + return controller + }()) + it.titlebarAppearsTransparent = true it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.collectionBehavior = [ + .fullScreenAuxiliary, + .transient, + .fullScreenPrimary, + .fullScreenAllowsTiling, + ] it.hasShadow = true it.contentView = NSHostingView( rootView: ChatWindowView( store: store.scope( state: \.chatPanelState, action: WidgetFeature.Action.chatPanel - ) + ), + toggleVisibility: { [weak it] isDisplayed in + guard let window = it else { return } + window.isPanelDisplayed = isDisplayed + } ) .environment(\.chatTabPool, chatTabPool) ) it.setIsVisible(true) + it.isPanelDisplayed = false it.delegate = self return it }() @@ -250,29 +275,20 @@ extension SuggestionWidgetController: NSWindowDelegate { store.send(.chatPanel(.detachChatPanel)) } } - - public func windowDidBecomeKey(_ notification: Notification) { + + public func windowWillEnterFullScreen(_ notification: Notification) { guard (notification.object as? NSWindow) === chatPanelWindow else { return } - let screenFrame = NSScreen.screens.first(where: { $0.frame.origin == .zero })? - .frame ?? .zero - var mouseLocation = NSEvent.mouseLocation - let windowFrame = chatPanelWindow.frame - if mouseLocation.y > windowFrame.maxY - Style.chatWindowTitleBarHeight, - mouseLocation.y < windowFrame.maxY, - mouseLocation.x > windowFrame.minX, - mouseLocation.x < windowFrame.maxX - { - mouseLocation.y = screenFrame.size.height - mouseLocation.y - if let cgEvent = CGEvent( - mouseEventSource: nil, - mouseType: .leftMouseDown, - mouseCursorPosition: mouseLocation, - mouseButton: .left - ), - let event = NSEvent(cgEvent: cgEvent) - { - chatPanelWindow.performDrag(with: event) - } + Task { @MainActor in + await Task.yield() + store.send(.chatPanel(.enterFullScreen)) + } + } + + public func windowWillExitFullScreen(_ notification: Notification) { + guard (notification.object as? NSWindow) === chatPanelWindow else { return } + Task { @MainActor in + await Task.yield() + store.send(.chatPanel(.exitFullScreen)) } } } @@ -289,16 +305,29 @@ class ChatWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } - override func mouseDown(with event: NSEvent) { - let windowFrame = frame - let currentLocation = event.locationInWindow - if currentLocation.y > windowFrame.size.height - Style.chatWindowTitleBarHeight, - currentLocation.y < windowFrame.size.height, - currentLocation.x > 0, - currentLocation.x < windowFrame.width - { - performDrag(with: event) + + var minimizeWindow: () -> Void = {} + + var isWindowHidden: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + var isPanelDisplayed: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + override var alphaValue: CGFloat { + didSet { + ignoresMouseEvents = alphaValue <= 0 } } + + override func miniaturize(_: Any?) { + minimizeWindow() + } } diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index ed37d3f3..d8f12097 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,6 +1,5 @@ import ActiveApplicationMonitor import ComposableArchitecture -import Environment import Preferences import SuggestionModel import SwiftUI diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index 5084d111..b8260c31 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -1,6 +1,5 @@ import AppKit import Client -import Environment import Foundation import GitHubCopilotService import SuggestionModel diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 7951c756..199a5ab4 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -12,6 +12,14 @@ extension AppDelegate { .init("xcodeInspectorDebugMenu") } + fileprivate var accessibilityAPIPermissionMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("accessibilitAPIPermissionMenuItem") + } + + fileprivate var sourceEditorDebugMenu: NSUserInterfaceItemIdentifier { + .init("sourceEditorDebugMenu") + } + @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem( @@ -61,6 +69,13 @@ extension AppDelegate { xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu xcodeInspectorDebug.isHidden = false + let accessibilityAPIPermission = NSMenuItem( + title: "Accessibility API Permission: N/A", + action: nil, + keyEquivalent: "" + ) + accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier + let quitItem = NSMenuItem( title: "Quit", action: #selector(quit), @@ -75,6 +90,7 @@ extension AppDelegate { statusBarMenu.addItem(openGlobalChat) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(xcodeInspectorDebug) + statusBarMenu.addItem(accessibilityAPIPermission) statusBarMenu.addItem(quitItem) statusBarMenu.delegate = self @@ -92,6 +108,15 @@ extension AppDelegate: NSMenuDelegate { xcodeInspectorDebug.isHidden = !UserDefaults.shared .value(for: \.enableXcodeInspectorDebugMenu) } + + if let accessibilityAPIPermission = menu.items.first(where: { item in + item.identifier == accessibilityAPIPermissionMenuItemIdentifier + }) { + AXIsProcessTrusted() + accessibilityAPIPermission.title = + "Accessibility API Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")" + } + case xcodeInspectorDebugMenuIdentifier: let inspector = XcodeInspector.shared menu.items.removeAll() @@ -101,6 +126,33 @@ extension AppDelegate: NSMenuDelegate { .append(.text("Active Workspace: \(inspector.activeWorkspaceURL?.path ?? "N/A")")) menu.items .append(.text("Active Document: \(inspector.activeDocumentURL?.path ?? "N/A")")) + + if let focusedWindow = inspector.focusedWindow { + menu.items.append(.text( + "Active Window: \(focusedWindow.uiElement.identifier)" + )) + } else { + menu.items.append(.text("Active Window: N/A")) + } + + if let focusedElement = inspector.focusedElement { + menu.items.append(.text( + "Focused Element: \(focusedElement.description)" + )) + } else { + menu.items.append(.text("Focused Element: N/A")) + } + + if let sourceEditor = inspector.focusedEditor { + menu.items.append(.text( + "Active Source Editor: \(sourceEditor.element.isSourceEditor ? "Found" : "Error")" + )) + } else { + menu.items.append(.text("Active Source Editor: N/A")) + } + + menu.items.append(.separator()) + for xcode in inspector.xcodes { let item = NSMenuItem( title: "Xcode \(xcode.runningApplication.processIdentifier)", @@ -117,7 +169,7 @@ extension AppDelegate: NSMenuDelegate { .append(.text("Active Workspace: \(xcode.workspaceURL?.path ?? "N/A")")) xcodeMenu.items .append(.text("Active Document: \(xcode.documentURL?.path ?? "N/A")")) - + for (key, workspace) in xcode.realtimeWorkspaces { let workspaceItem = NSMenuItem( title: "Workspace \(key)", @@ -140,12 +192,27 @@ extension AppDelegate: NSMenuDelegate { } } } + + menu.items.append(.separator()) + + menu.items.append(NSMenuItem( + title: "Restart Xcode Inspector", + action: #selector(restartXcodeInspector), + keyEquivalent: "" + )) + default: break } } } +private extension AppDelegate { + @objc func restartXcodeInspector() { + XcodeInspector.shared.restart(cleanUp: true) + } +} + private extension NSMenuItem { static func text(_ text: String) -> NSMenuItem { let item = NSMenuItem( @@ -157,3 +224,4 @@ private extension NSMenuItem { return item } } + diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 243c0bee..b0d3936b 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,4 +1,3 @@ -import Environment import FileChangeChecker import LaunchAgentManager import Logger diff --git a/Pro b/Pro index 51539dcb..59d36209 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 51539dcbe8810b8409a51764e3fb458296cf1d01 +Subproject commit 59d3620976c45a2dafdf50332317cca266a32488 diff --git a/Tool/Package.swift b/Tool/Package.swift index 7bf67804..558e51e2 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -19,7 +19,6 @@ let package = Package( name: "ChatContextCollector", targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"] ), - .library(name: "Environment", targets: ["Environment"]), .library(name: "SuggestionModel", targets: ["SuggestionModel"]), .library(name: "ASTParser", targets: ["ASTParser"]), .library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]), @@ -60,7 +59,7 @@ let package = Package( url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.55.0" ), - .package(url: "https://github.com/apple/swift-syntax.git", branch: "main"), + .package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"), .package(url: "https://github.com/google/generative-ai-swift", from: "0.4.4"), @@ -102,16 +101,6 @@ let package = Package( )] ), - .target( - name: "Environment", - dependencies: [ - "ActiveApplicationMonitor", - "XcodeInspector", - "AXExtension", - "Preferences", - ] - ), - .target( name: "AppActivator", dependencies: [ @@ -208,7 +197,6 @@ let package = Package( "GitIgnoreCheck", "UserDefaultsObserver", "SuggestionModel", - "Environment", "Logger", "Preferences", "XcodeInspector", diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 5d17ff4a..88b1339c 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -45,7 +45,10 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { var functions = [any ChatGPTFunction]() if !isSensitive { - functions.append(GetCodeCodeAroundLineFunction(contextCollector: self)) + functions.append(GetCodeCodeAroundLineFunction( + contextCollector: self, + additionalDescription: "You already have the code in focusing range, don't get it again!" + )) } return .init( diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift index 16f2bab3..29726320 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift @@ -32,7 +32,7 @@ struct GetCodeCodeAroundLineFunction: ChatGPTFunction { } var description: String { - "Get the code at the given line. You must ONLY call it when the user give you a specific line or the user ask about an out of scope annotation." + "Get the code at the given line. You must ONLY call it when the user give you a specific line or the user ask about an out of scope annotation. \n\(additionalDescription)" } var argumentSchema: JSONSchemaValue { [ @@ -47,9 +47,12 @@ struct GetCodeCodeAroundLineFunction: ChatGPTFunction { ] } weak var contextCollector: ActiveDocumentChatContextCollector? + + let additionalDescription: String - init(contextCollector: ActiveDocumentChatContextCollector) { + init(contextCollector: ActiveDocumentChatContextCollector, additionalDescription: String = "") { self.contextCollector = contextCollector + self.additionalDescription = additionalDescription } func prepare(reportProgress: @escaping (String) async -> Void) async { diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift deleted file mode 100644 index 445159de..00000000 --- a/Tool/Sources/Environment/Environment.swift +++ /dev/null @@ -1,268 +0,0 @@ -import ActiveApplicationMonitor -import AppKit -import AXExtension -import Foundation -import Logger -import Preferences -import XcodeInspector - -public struct NoAccessToAccessibilityAPIError: Error, LocalizedError { - public var errorDescription: String? { - "Accessibility API permission is not granted. Please enable in System Settings.app." - } - - public init() {} -} - -public struct FailedToFetchFileURLError: Error, LocalizedError { - public var errorDescription: String? { - "Failed to fetch editing file url." - } - - public init() {} -} - -public enum Environment { - public static var now = { Date() } - - public static var isXcodeActive: () async -> Bool = { - ActiveApplicationMonitor.shared.activeXcode != nil - } - - public static var frontmostXcodeWindowIsEditor: () async -> Bool = { - let appleScript = """ - tell application "Xcode" - return path of document of the first window - end tell - """ - do { - let result = try await runAppleScript(appleScript) - return !result.isEmpty - } catch { - return false - } - } - - #warning("TODO: Use XcodeInspector instead.") - public static var fetchCurrentWorkspaceURLFromXcode: () async throws -> URL? = { - if let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode - { - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedWindow = application.focusedWindow - for child in focusedWindow?.children ?? [] { - if child.description.starts(with: "/"), child.description.count > 1 { - let path = child.description - let trimmedNewLine = path.trimmingCharacters(in: .newlines) - var url = URL(fileURLWithPath: trimmedNewLine) - return url - } - } - } - - return nil - } - - public static var fetchCurrentProjectRootURLFromXcode: () async throws -> URL? = { - if var url = try await fetchCurrentWorkspaceURLFromXcode() { - return try await guessProjectRootURLForFile(url) - } - - return nil - } - - #warning("TODO: Use WorkspaceXcodeWindowInspector.extractProjectURL instead.") - public static var guessProjectRootURLForFile: (_ fileURL: URL) async throws -> URL = { - fileURL in - var currentURL = fileURL - var firstDirectoryURL: URL? - var lastGitDirectoryURL: URL? - while currentURL.pathComponents.count > 1 { - defer { currentURL.deleteLastPathComponent() } - guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } - guard currentURL.pathExtension != "xcodeproj" else { continue } - guard currentURL.pathExtension != "xcworkspace" else { continue } - guard currentURL.pathExtension != "playground" else { continue } - if firstDirectoryURL == nil { firstDirectoryURL = currentURL } - let gitURL = currentURL.appendingPathComponent(".git") - if FileManager.default.fileIsDirectory(atPath: gitURL.path) { - lastGitDirectoryURL = currentURL - } else if let text = try? String(contentsOf: gitURL) { - if !text.hasPrefix("gitdir: ../"), // it's not a sub module - text.range(of: "/.git/worktrees/") != nil // it's a git worktree - { - lastGitDirectoryURL = currentURL - } - } - } - - return lastGitDirectoryURL ?? firstDirectoryURL ?? fileURL - } - - public static var fetchCurrentFileURL: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode - else { - throw FailedToFetchFileURLError() - } - - // fetch file path of the frontmost window of Xcode through Accessability API. - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedWindow = application.focusedWindow - var path = focusedWindow?.document - if path == nil { - for window in application.windows { - path = window.document - if path != nil { break } - } - } - if let path = path?.removingPercentEncoding { - let url = URL( - fileURLWithPath: path - .replacingOccurrences(of: "file://", with: "") - ) - return url - } - throw FailedToFetchFileURLError() - } - - public static var fetchFocusedElementURI: () async throws -> URL = { - guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode - else { return URL(fileURLWithPath: "/global") } - - let application = AXUIElementCreateApplication(xcode.processIdentifier) - let focusedElement = application.focusedElement - var windowElement: URL { - let window = application.focusedWindow - let id = window?.identifier.hashValue - return URL(fileURLWithPath: "/xcode-focused-element/\(id ?? 0)") - } - if focusedElement?.description != "Source Editor" { - return windowElement - } - - do { - return try await fetchCurrentFileURL() - } catch { - return windowElement - } - } - - public static var triggerAction: (_ name: String) async throws -> Void = { name in - struct CantRunCommand: Error, LocalizedError { - let name: String - var errorDescription: String? { - "Can't run command \(name)." - } - } - - guard let activeXcode = XcodeInspector.shared.latestActiveXcode?.runningApplication - else { throw CantRunCommand(name: name) } - - let bundleName = Bundle.main - .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String - - await Task.yield() - - if UserDefaults.shared.value(for: \.triggerActionWithAccessibilityAPI) { - if !activeXcode.isActive { activeXcode.activate() } - let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - - if let editorMenu = app.menuBar?.child(title: "Editor"), - let commandMenu = editorMenu.child(title: bundleName) - { - if let button = commandMenu.child(title: name, role: "AXMenuItem") { - let error = AXUIElementPerformAction(button, kAXPressAction as CFString) - if error != AXError.success { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } else { - return - } - } - } else if let commandMenu = app.menuBar?.child(title: bundleName), - let button = commandMenu.child(title: name, role: "AXMenuItem") - { - let error = AXUIElementPerformAction(button, kAXPressAction as CFString) - if error != AXError.success { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } else { - return - } - } - - throw CantRunCommand(name: name) - } else { - /// check if menu is open, if not, click the menu item. - let appleScript = """ - tell application "System Events" - set theprocs to every process whose unix id is \(activeXcode.processIdentifier) - repeat with proc in theprocs - set the frontmost of proc to true - tell proc - repeat with theMenu in menus of menu bar 1 - set theValue to value of attribute "AXVisibleChildren" of theMenu - if theValue is not {} then - return - end if - end repeat - click menu item "\(name)" of menu 1 of menu item "\(bundleName)" of menu 1 of menu bar item "Editor" of menu bar 1 - end tell - end repeat - end tell - """ - - do { - try await runAppleScript(appleScript) - } catch { - Logger.service - .error("Trigger command \(name) failed: \(error.localizedDescription)") - throw error - } - } - } -} - -@discardableResult -func runAppleScript(_ appleScript: String) async throws -> String { - let task = Process() - task.launchPath = "/usr/bin/osascript" - task.arguments = ["-e", appleScript] - let outpipe = Pipe() - task.standardOutput = outpipe - task.standardError = Pipe() - - return try await withUnsafeThrowingContinuation { continuation in - do { - task.terminationHandler = { _ in - do { - if let data = try outpipe.fileHandleForReading.readToEnd(), - let content = String(data: data, encoding: .utf8) - { - continuation.resume(returning: content) - return - } - continuation.resume(returning: "") - } catch { - continuation.resume(throwing: error) - } - } - try task.run() - } catch { - continuation.resume(throwing: error) - } - } -} - -public extension FileManager { - func fileIsDirectory(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - let exists = fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue && exists - } -} - diff --git a/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift index e47a001f..c78257b4 100644 --- a/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/KnownLanguageFocusedCodeFinder.swift @@ -57,7 +57,8 @@ public protocol KnownLanguageFocusedCodeFinderType: FocusedCodeFinderType { func contextContainingNode( _ node: Node, - textProvider: @escaping TextProvider + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter ) -> NodeInfo? func createTextProviderAndRangeConverter( @@ -92,7 +93,11 @@ public extension KnownLanguageFocusedCodeFinderType { var focusedNode: Node? while let node = contextInfo.nodes.first { contextInfo.nodes.removeFirst() - let nodeInfo = contextContainingNode(node, textProvider: textProvider) + let nodeInfo = contextContainingNode( + node, + textProvider: textProvider, + rangeConverter: rangeConverter + ) if nodeInfo?.canBeUsedAsCodeRange ?? false { focusedNode = node break @@ -126,7 +131,7 @@ public extension KnownLanguageFocusedCodeFinderType { return .init( scope: scopeContexts.isEmpty ? .file : .scope(signature: scopeContexts), - contextRange: contextRange, + contextRange: contextRange, smallestContextRange: codeRange, focusedRange: focusedRange, focusedCode: code, @@ -187,7 +192,11 @@ extension KnownLanguageFocusedCodeFinderType { while let node = nodes.first { nodes.removeFirst() - let context = contextContainingNode(node, textProvider: textProvider) + let context = contextContainingNode( + node, + textProvider: textProvider, + rangeConverter: rangeConverter + ) if let context { contextRange = rangeConverter(context.node) diff --git a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift index cb3103c4..0fa7dda2 100644 --- a/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/ObjectiveC/ObjectiveCCodeFinder.swift @@ -65,7 +65,8 @@ public class ObjectiveCFocusedCodeFinder: KnownLanguageFocusedCodeFinder< public func contextContainingNode( _ node: Node, - textProvider: @escaping TextProvider + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter ) -> NodeInfo? { switch ObjectiveCNodeType(rawValue: node.nodeType ?? "") { case .classInterface, .categoryInterface: diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift index 3eb7a91f..368cb015 100644 --- a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift @@ -45,7 +45,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< tree: SourceFileSyntax ) -> (TextProvider, RangeConverter) { let locationConverter = SourceLocationConverter( - file: document.documentURL.path, + fileName: document.documentURL.path, tree: tree ) return ( @@ -62,7 +62,8 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< public func contextContainingNode( _ node: SyntaxProtocol, - textProvider: @escaping TextProvider + textProvider: @escaping TextProvider, + rangeConverter: @escaping RangeConverter ) -> NodeInfo? { func extractText(_ node: SyntaxProtocol) -> String { textProvider(node) @@ -71,7 +72,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< switch node { case let node as StructDeclSyntax: let type = node.structKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -84,7 +85,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as ClassDeclSyntax: let type = node.classKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -97,7 +98,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as EnumDeclSyntax: let type = node.enumKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -110,7 +111,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as ActorDeclSyntax: let type = node.actorKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -123,7 +124,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as MacroDeclSyntax: let type = node.macroKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -135,7 +136,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as ProtocolDeclSyntax: let type = node.protocolKeyword.text - let name = node.identifier.text + let name = node.name.text return .init( node: node, signature: "\(type) \(name)" @@ -161,7 +162,7 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as FunctionDeclSyntax: let type = node.funcKeyword.text - let name = node.identifier.text + let name = node.name.text let signature = node.signature.trimmedDescription .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -243,18 +244,19 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< case let node as ClosureExprSyntax: let signature = "closure" + let range = rangeConverter(node) return .init( node: node, signature: signature .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) .joined(separator: " "), - name: "closure" + name: "closure", + canBeUsedAsCodeRange: range.lineCount > 80 ) case let node as FunctionCallExprSyntax: let signature = "function call" - return .init( node: node, signature: signature @@ -265,12 +267,15 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< ) case let node as SwitchCaseSyntax: + let range = rangeConverter(node) + return .init( node: node, signature: node.trimmedDescription .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) .joined(separator: " "), - name: "switch" + name: "switch", + canBeUsedAsCodeRange: range.lineCount > 80 ) default: @@ -293,15 +298,15 @@ public class SwiftFocusedCodeFinder: KnownLanguageFocusedCodeFinder< func findTypeNameFromNode(_ node: SyntaxProtocol) -> String? { switch node { case let node as ClassDeclSyntax: - return node.identifier.text + return node.name.text case let node as StructDeclSyntax: - return node.identifier.text + return node.name.text case let node as EnumDeclSyntax: - return node.identifier.text + return node.name.text case let node as ActorDeclSyntax: - return node.identifier.text + return node.name.text case let node as ProtocolDeclSyntax: - return node.identifier.text + return node.name.text case let node as ExtensionDeclSyntax: return node.extendedType.trimmedDescription default: @@ -322,18 +327,18 @@ extension CursorRange { // MARK: - Helper Types protocol AttributeAndModifierApplicableSyntax { - var attributes: AttributeListSyntax? { get } - var modifiers: ModifierListSyntax? { get } + var attributes: AttributeListSyntax { get } + var modifiers: DeclModifierListSyntax { get } } extension AttributeAndModifierApplicableSyntax { func modifierAndAttributeText(_ extractText: (SyntaxProtocol) -> String) -> String { - let attributeTexts = attributes?.map { attribute in + let attributeTexts = attributes.map { attribute in extractText(attribute) - } ?? [] - let modifierTexts = modifiers?.map { modifier in + } + let modifierTexts = modifiers.map { modifier in extractText(modifier) - } ?? [] + } let prefix = (attributeTexts + modifierTexts).joined(separator: " ") return prefix } @@ -352,13 +357,13 @@ extension VariableDeclSyntax: AttributeAndModifierApplicableSyntax {} extension InitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} extension DeinitializerDeclSyntax: AttributeAndModifierApplicableSyntax {} extension AccessorDeclSyntax: AttributeAndModifierApplicableSyntax { - var modifiers: SwiftSyntax.ModifierListSyntax? { nil } + var modifiers: SwiftSyntax.DeclModifierListSyntax { [] } } extension SubscriptDeclSyntax: AttributeAndModifierApplicableSyntax {} protocol InheritanceClauseApplicableSyntax { - var inheritanceClause: TypeInheritanceClauseSyntax? { get } + var inheritanceClause: InheritanceClauseSyntax? { get } } extension StructDeclSyntax: InheritanceClauseApplicableSyntax {} @@ -370,7 +375,7 @@ extension ExtensionDeclSyntax: InheritanceClauseApplicableSyntax {} extension InheritanceClauseApplicableSyntax { func inheritanceClauseTexts(_ extractText: (SyntaxProtocol) -> String) -> String { - inheritanceClause?.inheritedTypeCollection.map { clause in + inheritanceClause?.inheritedTypes.map { clause in extractText(clause).trimmingCharacters(in: [","]) }.joined(separator: ", ") ?? "" } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift index c706f81f..2e24b6d9 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift @@ -104,7 +104,7 @@ public struct GitHubCopilotInstallationManager { _ = try await terminal.runCommand( "/usr/bin/unzip", arguments: [targetURL.path], - currentDirectoryPath: urls.executableURL.path, + currentDirectoryURL: urls.executableURL, environment: [:] ) diff --git a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift index ebebfc6d..b2c48114 100644 --- a/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift +++ b/Tool/Sources/GitIgnoreCheck/GitIgnoreCheck.swift @@ -55,7 +55,7 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { let result = try await terminal.runCommand( "/bin/bash", arguments: ["-c", "git check-ignore \"\(fileURL.path)\""], - currentDirectoryPath: gitFolderURL.path, + currentDirectoryURL: gitFolderURL, environment: [:] ) if result.isEmpty { return false } @@ -77,7 +77,7 @@ public struct DefaultGitIgnoredChecker: GitIgnoredChecker { let result = try await terminal.runCommand( "/bin/bash", arguments: ["-c", "git check-ignore \(filePaths)"], - currentDirectoryPath: gitFolderURL.path, + currentDirectoryURL: gitFolderURL, environment: [:] ) return result diff --git a/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift index d71e147b..00edc4fc 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/RecursiveCharacterTextSplitter.swift @@ -19,7 +19,7 @@ public class RecursiveCharacterTextSplitter: TextSplitter { /// - chunkOverlap: The maximum overlap between chunks. /// - lengthFunction: A function to compute the length of text. public init( - separators: [String] = ["\n\n", "\r\n", "\n", "\r", " ", ""], + separators: [String], chunkSize: Int = 4000, chunkOverlap: Int = 200, lengthFunction: @escaping (String) -> Int = { $0.count } @@ -39,7 +39,7 @@ public class RecursiveCharacterTextSplitter: TextSplitter { /// - chunkOverlap: The maximum overlap between chunks. /// - lengthFunction: A function to compute the length of text. public init( - separatorSet: TextSplitterSeparatorSet, + separatorSet: TextSplitterSeparatorSet = .default, chunkSize: Int = 4000, chunkOverlap: Int = 200, lengthFunction: @escaping (String) -> Int = { $0.count } @@ -51,12 +51,12 @@ public class RecursiveCharacterTextSplitter: TextSplitter { separators = separatorSet.separators } - public func split(text: String) async throws -> [String] { - return split(text: text, separators: separators) + public func split(text: String) async throws -> [TextChunk] { + return split(text: text, separators: separators, startIndex: 0) } - private func split(text: String, separators: [String]) -> [String] { - var finalChunks = [String]() + private func split(text: String, separators: [String], startIndex: Int) -> [TextChunk] { + var finalChunks = [TextChunk]() // Get appropriate separator to use let firstSeparatorIndex = separators.firstIndex { @@ -83,12 +83,12 @@ public class RecursiveCharacterTextSplitter: TextSplitter { nextSeparators = [] } - let splits = split(text: text, separator: separator) + let splits = split(text: text, separator: separator, startIndex: startIndex) // Now go merging things, recursively splitting longer texts. - var goodSplits = [String]() + var goodSplits = [TextChunk]() for s in splits { - if lengthFunction(s) < chunkSize { + if lengthFunction(s.text) < chunkSize { goodSplits.append(s) } else { if !goodSplits.isEmpty { @@ -99,7 +99,11 @@ public class RecursiveCharacterTextSplitter: TextSplitter { if nextSeparators.isEmpty { finalChunks.append(s) } else { - let other_info = split(text: s, separators: nextSeparators) + let other_info = split( + text: s.text, + separators: nextSeparators, + startIndex: s.startUTF16Offset + ) finalChunks.append(contentsOf: other_info) } } diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 8c4db79c..4e467106 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -11,7 +11,7 @@ public protocol TextSplitter: DocumentTransformer { var lengthFunction: (String) -> Int { get } /// Split text into multiple components. - func split(text: String) async throws -> [String] + func split(text: String) async throws -> [TextChunk] } public extension TextSplitter { @@ -26,7 +26,7 @@ public extension TextSplitter { for (text, metadata) in zip(texts, metadata) { let chunks = try await split(text: text) for chunk in chunks { - let document = Document(pageContent: chunk, metadata: metadata) + let document = Document(pageContent: chunk.text, metadata: metadata) documents.append(document) } } @@ -50,31 +50,61 @@ public extension TextSplitter { } } +public struct TextChunk: Equatable { + public var text: String + public var startUTF16Offset: Int + public var endUTF16Offset: Int + + /// Merge the current chunk with another chunk if the 2 chunks are overlapping or adjacent. + public func merged(with chunk: TextChunk, force: Bool = false) -> TextChunk? { + let frontChunk = startUTF16Offset < chunk.startUTF16Offset ? self : chunk + let backChunk = startUTF16Offset < chunk.startUTF16Offset ? chunk : self + let overlap = frontChunk.endUTF16Offset - backChunk.startUTF16Offset + guard overlap >= 0 || force else { return nil } + + let text = frontChunk.text + backChunk.text.dropFirst(max(0, overlap)) + let start = frontChunk.startUTF16Offset + let end = backChunk.endUTF16Offset + return TextChunk(text: text, startUTF16Offset: start, endUTF16Offset: end) + } +} + public extension TextSplitter { /// Merge small splits to just fit in the chunk size. - func mergeSplits(_ splits: [String]) -> [String] { + func mergeSplits(_ splits: [TextChunk]) -> [TextChunk] { let chunkOverlap = chunkOverlap < chunkSize ? chunkOverlap : 0 - var chunks = [String]() - var currentChunk = [String]() - var overlappingChunks = [String]() + var chunks = [TextChunk]() + var currentChunk = [TextChunk]() + var overlappingChunks = [TextChunk]() var currentChunkSize = 0 - - func join(_ a: [String], _ b: [String]) -> String { - return (a + b).joined().trimmingCharacters(in: .whitespaces) + + func join(_ a: [TextChunk], _ b: [TextChunk]) -> TextChunk? { + let text = (a + b).map(\.text).joined() + var l = Int.max + var u = 0 + + for chunk in a + b { + l = min(l, chunk.startUTF16Offset) + u = max(u, chunk.endUTF16Offset) + } + + guard l < u else { return nil } + + return .init(text: text, startUTF16Offset: l, endUTF16Offset: u) } - for text in splits { - let textLength = lengthFunction(text) + for chunk in splits { + let textLength = lengthFunction(chunk.text) if currentChunkSize + textLength > chunkSize { - let currentChunkText = join(overlappingChunks, currentChunk) + guard let currentChunkText = join(overlappingChunks, currentChunk) else { continue } chunks.append(currentChunkText) overlappingChunks = [] var overlappingSize = 0 // use small chunks as overlap if possible for chunk in currentChunk.reversed() { - let length = lengthFunction(chunk) + let length = lengthFunction(chunk.text) if overlappingSize + length > chunkOverlap { break } if overlappingSize + length + textLength > chunkSize { break } overlappingSize += length @@ -90,46 +120,57 @@ public extension TextSplitter { // } currentChunkSize = overlappingSize + textLength - currentChunk = [text] + currentChunk = [chunk] } else { currentChunkSize += textLength - currentChunk.append(text) + currentChunk.append(chunk) } } - if !currentChunk.isEmpty { - chunks.append(join(overlappingChunks, currentChunk)) + if !currentChunk.isEmpty, let joinedChunks = join(overlappingChunks, currentChunk) { + chunks.append(joinedChunks) + } else { + chunks.append(contentsOf: overlappingChunks) + chunks.append(contentsOf: currentChunk) } return chunks } /// Split the text by separator. - func split(text: String, separator: String) -> [String] { - guard !separator.isEmpty else { - return [text] - } - + func split(text: String, separator: String, startIndex: Int = 0) -> [TextChunk] { let pattern = "(\(separator))" - if let regex = try? NSRegularExpression(pattern: pattern) { + if !separator.isEmpty, let regex = try? NSRegularExpression(pattern: pattern) { let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - var all = [String]() + var all = [TextChunk]() var start = text.startIndex for match in matches { guard let range = Range(match.range, in: text) else { break } guard range.lowerBound > start else { break } let result = text[start.. CompletionResponseBody { let aiModel = GenerativeModel( @@ -17,14 +18,14 @@ struct GoogleCompletionAPI: CompletionAPI { topP: requestBody.top_p.map(Float.init) )) ) - let history = requestBody.messages.map { message in + let history = prompt.googleAICompatible.history.map { message in ModelContent( ChatMessage( role: message.role, content: message.content, name: message.name, - functionCall: message.function_call.map { - .init(name: $0.name, arguments: $0.arguments ?? "") + functionCall: message.functionCall.map { + .init(name: $0.name, arguments: $0.arguments) } ) ) @@ -32,7 +33,7 @@ struct GoogleCompletionAPI: CompletionAPI { do { let response = try await aiModel.generateContent(history) - + return .init( object: "chat.completion", model: model.info.modelName, @@ -64,7 +65,7 @@ struct GoogleCompletionAPI: CompletionAPI { return "Internal Error: \(s)" } } - + switch error { case let .internalError(underlying): throw ErrorWrapper(error: underlying) @@ -79,3 +80,119 @@ struct GoogleCompletionAPI: CompletionAPI { } } +extension ChatGPTPrompt { + var googleAICompatible: ChatGPTPrompt { + var history = self.history + var reformattedHistory = [ChatMessage]() + + // We don't want to combine the new user message with others. + let newUserMessage: ChatMessage? = if history.last?.role == .user { + history.removeLast() + } else { + nil + } + + for message in history { + let lastIndex = reformattedHistory.endIndex - 1 + guard lastIndex >= 0 else { // first message + if message.role == .system { + reformattedHistory.append(.init( + id: message.id, + role: .user, + content: ModelContent.convertContent(of: message) + )) + reformattedHistory.append(.init( + role: .assistant, + content: "Got it. Let's start our conversation." + )) + continue + } + + reformattedHistory.append(message) + continue + } + + let lastMessage = reformattedHistory[lastIndex] + + if ModelContent.convertRole(lastMessage.role) == ModelContent + .convertRole(message.role) + { + let newMessage = ChatMessage( + id: message.id, + role: message.role == .assistant ? .assistant : .user, + content: """ + \(ModelContent.convertContent(of: lastMessage)) + + ====== + + \(ModelContent.convertContent(of: message)) + """ + ) + reformattedHistory[lastIndex] = newMessage + } else { + reformattedHistory.append(message) + } + } + + if let newUserMessage { + if let last = reformattedHistory.last, + ModelContent.convertRole(last.role) == ModelContent + .convertRole(newUserMessage.role) + { + // Add dummy message + let dummyMessage = ChatMessage( + role: .assistant, + content: "OK" + ) + reformattedHistory.append(dummyMessage) + } + reformattedHistory.append(newUserMessage) + } + + return .init( + history: reformattedHistory, + references: references, + remainingTokenCount: remainingTokenCount + ) + } +} + +extension ModelContent { + static func convertRole(_ role: ChatMessage.Role) -> String { + switch role { + case .user, .system, .function: + return "user" + case .assistant: + return "model" + } + } + + static func convertContent(of message: ChatMessage) -> String { + switch message.role { + case .system: + return "System Prompt:\n\(message.content ?? " ")" + case .user: + return message.content ?? " " + case .function: + return """ + Result of \(message.name ?? "function"): \(message.content ?? "N/A") + """ + case .assistant: + if let functionCall = message.functionCall { + return """ + Call function: \(functionCall.name) + Arguments: \(functionCall.arguments) + """ + } else { + return message.content ?? " " + } + } + } + + init(_ message: ChatMessage) { + 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/APIs/GoogleAICompletionStreamAPI.swift b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionStreamAPI.swift index 404246bf..47492340 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAICompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionStreamAPI.swift @@ -7,6 +7,7 @@ struct GoogleCompletionStreamAPI: CompletionStreamAPI { let apiKey: String let model: ChatModel var requestBody: CompletionRequestBody + let prompt: ChatGPTPrompt func callAsFunction() async throws -> AsyncThrowingStream { let aiModel = GenerativeModel( @@ -17,14 +18,14 @@ struct GoogleCompletionStreamAPI: CompletionStreamAPI { topP: requestBody.top_p.map(Float.init) )) ) - let history = requestBody.messages.map { message in + let history = prompt.googleAICompatible.history.map { message in ModelContent( ChatMessage( role: message.role, content: message.content, name: message.name, - functionCall: message.function_call.map { - .init(name: $0.name, arguments: $0.arguments ?? "") + functionCall: message.functionCall.map { + .init(name: $0.name, arguments: $0.arguments) } ) ) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAICompletionAPI.swift b/Tool/Sources/OpenAIService/APIs/OpenAICompletionAPI.swift index fbbc8409..31e86492 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAICompletionAPI.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAICompletionAPI.swift @@ -2,7 +2,7 @@ import AIModel import Foundation import Preferences -typealias CompletionAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) +typealias CompletionAPIBuilder = (String, ChatModel, URL, CompletionRequestBody, ChatGPTPrompt) -> CompletionAPI protocol CompletionAPI { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAICompletionStreamAPI.swift b/Tool/Sources/OpenAIService/APIs/OpenAICompletionStreamAPI.swift index 472a3b1f..46c6b1ff 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAICompletionStreamAPI.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAICompletionStreamAPI.swift @@ -3,8 +3,13 @@ import AsyncAlgorithms import Foundation import Preferences -typealias CompletionStreamAPIBuilder = (String, ChatModel, URL, CompletionRequestBody) - -> any CompletionStreamAPI +typealias CompletionStreamAPIBuilder = ( + String, + ChatModel, + URL, + CompletionRequestBody, + ChatGPTPrompt +) -> any CompletionStreamAPI protocol CompletionStreamAPI { func callAsFunction() async throws -> AsyncThrowingStream diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 3bed823c..5d1480ca 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -70,10 +70,15 @@ public class ChatGPTService: ChatGPTServiceType { var runningTask: Task? var buildCompletionStreamAPI: CompletionStreamAPIBuilder = { - apiKey, model, endpoint, requestBody in + apiKey, model, endpoint, requestBody, prompt in switch model.format { case .googleAI: - return GoogleCompletionStreamAPI(apiKey: apiKey, model: model, requestBody: requestBody) + return GoogleCompletionStreamAPI( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt + ) case .openAI, .openAICompatible, .azureOpenAI: return OpenAICompletionStreamAPI( apiKey: apiKey, @@ -85,10 +90,15 @@ public class ChatGPTService: ChatGPTServiceType { } var buildCompletionAPI: CompletionAPIBuilder = { - apiKey, model, endpoint, requestBody in + apiKey, model, endpoint, requestBody, prompt in switch model.format { case .googleAI: - return GoogleCompletionAPI(apiKey: apiKey, model: model, requestBody: requestBody) + return GoogleCompletionAPI( + apiKey: apiKey, + model: model, + requestBody: requestBody, + prompt: prompt + ) case .openAI, .openAICompatible, .azureOpenAI: return OpenAICompletionAPI( apiKey: apiKey, @@ -305,7 +315,8 @@ extension ChatGPTService { configuration.apiKey, model, url, - requestBody + requestBody, + prompt ) #if DEBUG @@ -432,7 +443,8 @@ extension ChatGPTService { configuration.apiKey, model, url, - requestBody + requestBody, + prompt ) #if DEBUG diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 56577520..7675c9ff 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -12,7 +12,6 @@ public enum AutoManagedChatGPTMemoryActor: GlobalActor { protocol AutoManagedChatGPTMemoryStrategy { func countToken(_ message: ChatMessage) async -> Int func countToken(_ function: F) async -> Int - func reformat(_ prompt: ChatGPTPrompt) async -> ChatGPTPrompt } /// A memory that automatically manages the history according to max tokens and max message count. @@ -172,12 +171,10 @@ extension AutoManagedChatGPTMemory { """) #endif - let reformattedPrompt = await strategy.reformat(.init( + return .init( history: allMessages, references: retrievedContent - )) - - return reformattedPrompt + ) } func generateMandatoryMessages(strategy: AutoManagedChatGPTMemoryStrategy) async -> ( @@ -289,14 +286,10 @@ extension AutoManagedChatGPTMemory { text += """ Here are the information you know about the system and the project, \ separated by \(separator) - - """ - } else { - text += "\n\(separator)\n" } - text += content.content + text += "\n\n\(separator)[DOCUMENT \(index)]\n\n" + content.content } return .init(role: .user, content: text) @@ -307,7 +300,7 @@ extension AutoManagedChatGPTMemory { { var right = retrievedContent.count var left = 0 - var retrievedContent = retrievedContent + var gappedRetrievedContent = retrievedContent var tokenCount: Int? var proposedMessage = buildMessage(retrievedContent: []) @@ -345,7 +338,7 @@ extension AutoManagedChatGPTMemory { let (isValid, _tokenCount) = await checkValid(proposedMessage: _proposedMessage) if isValid { proposedMessage = _proposedMessage - retrievedContent = _retrievedContent + gappedRetrievedContent = _retrievedContent tokenCount = _tokenCount left = count + 1 } else { @@ -360,7 +353,7 @@ extension AutoManagedChatGPTMemory { } else { await strategy.countToken(proposedMessage) } - return (proposedMessage, retrievedContent, finalCount) + return (proposedMessage, gappedRetrievedContent, finalCount) } let (message, references, tokensCount) = await buildMessageThatFits() diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift index 51a2e42b..1451bf67 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift @@ -26,116 +26,7 @@ extension AutoManagedChatGPTMemory { // function is not supported. return 0 } - - /// Gemini only supports turn-based conversation. A user message must be followed - /// by an model message. - func reformat(_ prompt: ChatGPTPrompt) async -> ChatGPTPrompt { - var history = prompt.history - var reformattedHistory = [ChatMessage]() - - // We don't want to combine the new user message with others. - let newUserMessage: ChatMessage? = if history.last?.role == .user { - history.removeLast() - } else { - nil - } - - for message in history { - let lastIndex = reformattedHistory.endIndex - 1 - guard lastIndex >= 0 else { // first message - if message.role == .system { - reformattedHistory.append(.init( - role: .user, - content: ModelContent.convertContent(of: message) - )) - reformattedHistory.append(.init( - role: .assistant, - content: "Got it. Let's start our conversation." - )) - continue - } - - reformattedHistory.append(message) - continue - } - - let lastMessage = reformattedHistory[lastIndex] - - if ModelContent.convertRole(lastMessage.role) == ModelContent - .convertRole(message.role) - { - let newMessage = ChatMessage( - role: message.role == .assistant ? .assistant : .user, - content: """ - \(ModelContent.convertContent(of: lastMessage)) - - ====== - - \(ModelContent.convertContent(of: message)) - """ - ) - reformattedHistory[lastIndex] = newMessage - } else { - reformattedHistory.append(message) - } - } - - if let newUserMessage { - if let last = reformattedHistory.last, - ModelContent.convertRole(last.role) == ModelContent - .convertRole(newUserMessage.role) - { - // Add dummy message - let dummyMessage = ChatMessage( - role: .assistant, - content: "OK" - ) - reformattedHistory.append(dummyMessage) - } - reformattedHistory.append(newUserMessage) - } - - return .init( - history: reformattedHistory, - references: prompt.references, - remainingTokenCount: prompt.remainingTokenCount - ) - } } } -extension ModelContent { - static func convertRole(_ role: ChatMessage.Role) -> String { - switch role { - case .user, .system, .function: - return "user" - case .assistant: - return "model" - } - } - - static func convertContent(of message: ChatMessage) -> String { - switch message.role { - case .system: - return "System Prompt: \n\(message.content ?? " ")" - case .user, .function: - return message.content ?? " " - case .assistant: - if let functionCall = message.functionCall { - return """ - call function: \(functionCall.name) - arguments: \(functionCall.arguments) - """ - } else { - return message.content ?? " " - } - } - } - - init(_ message: ChatMessage) { - 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/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index 5a1f3f0b..07d72acb 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -22,10 +22,6 @@ extension AutoManagedChatGPTMemory { return await (nameTokenCount + descriptionTokenCount + schemaTokenCount) } - - func reformat(_ prompt: ChatGPTPrompt) async -> ChatGPTPrompt { - prompt - } } } diff --git a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift index 558b6cac..d27569d6 100644 --- a/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/ChatGPTMemory.swift @@ -1,6 +1,6 @@ import Foundation -public struct ChatGPTPrompt { +public struct ChatGPTPrompt: Equatable { public var history: [ChatMessage] public var references: [ChatMessage.Reference] public var remainingTokenCount: Int? diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 200c2fba..09ae7248 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -334,6 +334,10 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: true, key: "AcceptSuggestionWithTab") } + var dismissSuggestionWithEsc: PreferenceKey { + .init(defaultValue: true, key: "DismissSuggestionWithEsc") + } + var isSuggestionSenseEnabled: PreferenceKey { .init(defaultValue: false, key: "IsSuggestionSenseEnabled") } diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index 3e4b91c0..f4345fd0 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -52,6 +52,11 @@ public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringC return start.line == end.line } + /// The number of lines in the range. + public var lineCount: Int { + return end.line - start.line + 1 + } + public static func == (lhs: CursorRange, rhs: CursorRange) -> Bool { return lhs.start == rhs.start && lhs.end == rhs.end } diff --git a/Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift index 56d91704..a0478833 100644 --- a/Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift +++ b/Tool/Sources/SuggestionModel/LanguageIdentifierFromFilePath.swift @@ -30,6 +30,14 @@ public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { self = .other(rawValue) } } + + public init(fileURL: URL) { + self = languageIdentifierFromFileURL(fileURL) + } + + public init(filePath: String) { + self = languageIdentifierFromFileURL(URL(fileURLWithPath: filePath)) + } public static var allCases: [CodeLanguage] { var all = LanguageIdentifier.allCases.map(CodeLanguage.builtIn) diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift index 845d6742..89812c4b 100644 --- a/Tool/Sources/Terminal/Terminal.swift +++ b/Tool/Sources/Terminal/Terminal.swift @@ -5,14 +5,14 @@ public protocol TerminalType { func streamCommand( _ command: String, arguments: [String], - currentDirectoryPath: String, + currentDirectoryURL: URL?, environment: [String: String] ) -> AsyncThrowingStream func runCommand( _ command: String, arguments: [String], - currentDirectoryPath: String, + currentDirectoryURL: URL?, environment: [String: String] ) async throws -> String @@ -44,7 +44,7 @@ public final class Terminal: TerminalType, @unchecked Sendable { public func streamCommand( _ command: String = "/bin/bash", arguments: [String], - currentDirectoryPath: String = "/", + currentDirectoryURL: URL? = nil, environment: [String: String] ) -> AsyncThrowingStream { self.process?.terminate() @@ -52,7 +52,7 @@ public final class Terminal: TerminalType, @unchecked Sendable { self.process = process process.launchPath = command - process.currentDirectoryPath = currentDirectoryPath + process.currentDirectoryURL = currentDirectoryURL process.arguments = arguments process.environment = getEnvironmentVariables() .merging(environment, uniquingKeysWith: { $1 }) @@ -128,12 +128,12 @@ public final class Terminal: TerminalType, @unchecked Sendable { public func runCommand( _ command: String = "/bin/bash", arguments: [String], - currentDirectoryPath: String = "/", + currentDirectoryURL: URL? = nil, environment: [String: String] ) async throws -> String { let process = Process() process.launchPath = command - process.currentDirectoryPath = currentDirectoryPath + process.currentDirectoryURL = currentDirectoryURL process.arguments = arguments process.environment = getEnvironmentVariables() .merging(environment, uniquingKeysWith: { $1 }) diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index 5a9a7145..e6228803 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -1,5 +1,4 @@ import Dependencies -import Environment import Foundation import GitIgnoreCheck import SuggestionModel diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index bbffbecc..9facada9 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -1,10 +1,13 @@ -import Environment import Foundation import Preferences import SuggestionModel import UserDefaultsObserver import XcodeInspector +enum Environment { + static var now = { Date() } +} + public protocol WorkspacePropertyKey { associatedtype Value static func createDefaultValue() -> Value @@ -58,6 +61,12 @@ public final class Workspace { } } + public struct CantFindWorkspaceError: Error, LocalizedError { + public var errorDescription: String? { + "Can't find workspace." + } + } + private var additionalProperties = WorkspacePropertyValues() public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]() public let workspaceURL: URL diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift index 4c6c85d8..a5891e91 100644 --- a/Tool/Sources/Workspace/WorkspacePool.swift +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -1,6 +1,6 @@ -import Environment -import Foundation import Dependencies +import Foundation +import XcodeInspector public struct WorkspacePoolDependencyKey: DependencyKey { public static var liveValue: WorkspacePool = .init() @@ -21,15 +21,15 @@ public extension DependencyValues { public class WorkspacePool { public enum Error: Swift.Error, LocalizedError { case invalidWorkspaceURL(URL) - + public var errorDescription: String? { switch self { - case .invalidWorkspaceURL(let url): + case let .invalidWorkspaceURL(url): return "Invalid workspace URL: \(url)" } } } - + public internal(set) var workspaces: [URL: Workspace] = [:] var plugins = [ObjectIdentifier: (Workspace) -> WorkspacePlugin]() @@ -59,7 +59,7 @@ public class WorkspacePool { removePlugin(id: id, from: workspace) } } - + public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { for workspace in workspaces.values { if let filespace = workspace.filespaces[fileURL] { @@ -68,13 +68,13 @@ public class WorkspacePool { } return nil } - + @WorkspaceActor public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { guard workspaceURL != URL(fileURLWithPath: "/") else { throw Error.invalidWorkspaceURL(workspaceURL) } - + if let existed = workspaces[workspaceURL] { return existed } @@ -93,9 +93,10 @@ public class WorkspacePool { throw Workspace.UnsupportedFileError(extensionName: fileURL.pathExtension) } - // If we know which project is opened. - if let currentWorkspaceURL = try await Environment.fetchCurrentWorkspaceURLFromXcode() { + // If we can get the workspace URL directly. + if let currentWorkspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { + // Reuse the existed workspace. let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } @@ -116,30 +117,35 @@ public class WorkspacePool { } } - // If we can't find an existed one, we will try to guess it. + // If we can't find the workspace URL, we will try to guess it. // Most of the time we won't enter this branch, just incase. - let workspaceURL = try await Environment.guessProjectRootURLForFile(fileURL) - - let workspace = { - if let existed = workspaces[workspaceURL] { - return existed - } - // Reuse existed workspace if possible - for (_, workspace) in workspaces { - if fileURL.path.hasPrefix(workspace.projectRootURL.path) { - return workspace + if let workspaceURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: nil, + documentURL: fileURL + ) { + let workspace = { + if let existed = workspaces[workspaceURL] { + return existed } - } - return createNewWorkspace(workspaceURL: workspaceURL) - }() + // Reuse existed workspace if possible + for (_, workspace) in workspaces { + if fileURL.path.hasPrefix(workspace.projectRootURL.path) { + return workspace + } + } + return createNewWorkspace(workspaceURL: workspaceURL) + }() - let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) - workspaces[workspaceURL] = workspace - workspace.refreshUpdateTime() - return (workspace, filespace) + let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) + workspaces[workspaceURL] = workspace + workspace.refreshUpdateTime() + return (workspace, filespace) + } + + throw Workspace.CantFindWorkspaceError() } - + @WorkspaceActor public func removeWorkspace(url: URL) { workspaces[url] = nil diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift index 67f460bb..40b85ac6 100644 --- a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -1,4 +1,3 @@ -import Environment import Foundation import Preferences import SuggestionModel diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift new file mode 100644 index 00000000..1613a8b7 --- /dev/null +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -0,0 +1,17 @@ +import AppKit +import Foundation + +public class AppInstanceInspector: ObservableObject { + public var appElement: AXUIElement { + AXUIElementCreateApplication(runningApplication.processIdentifier) + } + public let runningApplication: NSRunningApplication + public var isActive: Bool { runningApplication.isActive } + public var isXcode: Bool { runningApplication.isXcode } + public var isExtensionService: Bool { runningApplication.isCopilotForXcodeExtensionService } + + init(runningApplication: NSRunningApplication) { + self.runningApplication = runningApplication + } +} + diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift new file mode 100644 index 00000000..7de0eb03 --- /dev/null +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -0,0 +1,320 @@ +import AppKit +import AXExtension +import AXNotificationStream +import Combine +import Foundation + +public final class XcodeAppInstanceInspector: AppInstanceInspector { + @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? + @Published public fileprivate(set) var documentURL: URL? = nil + @Published public fileprivate(set) var workspaceURL: URL? = nil + @Published public fileprivate(set) var projectRootURL: URL? = nil + @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() + public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + updateWorkspaceInfo() + return workspaces.mapValues(\.info) + } + + @Published public private(set) var completionPanel: AXUIElement? + + public var realtimeDocumentURL: URL? { + guard let window = appElement.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) + } + + public var realtimeWorkspaceURL: URL? { + guard let window = appElement.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + } + + public var realtimeProjectURL: URL? { + let workspaceURL = realtimeWorkspaceURL + let documentURL = realtimeDocumentURL + return WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: documentURL + ) + } + + var _version: String? + public var version: String? { + if let _version { return _version } + guard let plistPath = runningApplication.bundleURL? + .appendingPathComponent("Contents") + .appendingPathComponent("version.plist") + .path + else { return nil } + guard let plistData = FileManager.default.contents(atPath: plistPath) else { return nil } + var format = PropertyListSerialization.PropertyListFormat.xml + guard let plistDict = try? PropertyListSerialization.propertyList( + from: plistData, + options: .mutableContainersAndLeaves, + format: &format + ) as? [String: AnyObject] else { return nil } + let result = plistDict["CFBundleShortVersionString"] as? String + _version = result + return result + } + + private var longRunningTasks = Set>() + private var focusedWindowObservations = Set() + + deinit { + for task in longRunningTasks { task.cancel() } + } + + override init(runningApplication: NSRunningApplication) { + super.init(runningApplication: runningApplication) + + Task { @MainActor in + observeFocusedWindow() + observeAXNotifications() + + try await Task.sleep(nanoseconds: 3_000_000_000) + // Sometimes the focused window may not be ready on app launch. + if !(focusedWindow is WorkspaceXcodeWindowInspector) { + observeFocusedWindow() + } + } + } + + @MainActor + func observeFocusedWindow() { + if let window = appElement.focusedWindow { + if window.identifier == "Xcode.WorkspaceWindow" { + let window = WorkspaceXcodeWindowInspector( + app: runningApplication, + uiElement: window + ) + focusedWindow = window + + // should find a better solution to do this thread safe + Task { @MainActor in + focusedWindowObservations.forEach { $0.cancel() } + focusedWindowObservations.removeAll() + + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL + + window.$documentURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.documentURL = url + }.store(in: &focusedWindowObservations) + window.$workspaceURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.workspaceURL = url + }.store(in: &focusedWindowObservations) + window.$projectRootURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.projectRootURL = url + }.store(in: &focusedWindowObservations) + } + } else { + let window = XcodeWindowInspector(uiElement: window) + focusedWindow = window + } + } else { + focusedWindow = nil + } + } + + @MainActor + func refresh() { + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + focusedWindow.refresh() + } else { + observeFocusedWindow() + } + } + + @MainActor + func observeAXNotifications() { + longRunningTasks.forEach { $0.cancel() } + longRunningTasks = [] + + let windowChangeNotification = AXNotificationStream( + app: runningApplication, + notificationNames: kAXFocusedWindowChangedNotification + ) + + let focusedWindowChanged = Task { @MainActor [weak self] in + for await _ in windowChangeNotification { + guard let self else { return } + try Task.checkCancellation() + observeFocusedWindow() + } + } + + longRunningTasks.insert(focusedWindowChanged) + + updateWorkspaceInfo() + + let elementChangeNotification = AXNotificationStream( + app: runningApplication, + notificationNames: kAXFocusedUIElementChangedNotification, + kAXApplicationDeactivatedNotification + ) + + let updateTabsTask = Task { @MainActor [weak self] in + if #available(macOS 13.0, *) { + for await _ in elementChangeNotification.debounce(for: .seconds(2)) { + guard let self else { return } + try Task.checkCancellation() + updateWorkspaceInfo() + } + } else { + for await _ in elementChangeNotification { + guard let self else { return } + try Task.checkCancellation() + updateWorkspaceInfo() + } + } + } + + longRunningTasks.insert(updateTabsTask) + + let completionPanelNotification = AXNotificationStream( + app: runningApplication, + notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification + ) + + let completionPanelTask = Task { @MainActor [weak self] in + for await event in completionPanelNotification { + guard let self else { return } + + // We can only observe the creation and closing of the parent + // of the completion panel. + let isCompletionPanel = { + event.element.identifier == "_XC_COMPLETION_TABLE_" + || event.element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil + } + switch event.name { + case kAXCreatedNotification: + if isCompletionPanel() { + completionPanel = event.element + } + case kAXUIElementDestroyedNotification: + if isCompletionPanel() { + completionPanel = nil + } + default: break + } + + try Task.checkCancellation() + } + } + + longRunningTasks.insert(completionPanelTask) + } +} + +// MARK: - Workspace Info + +extension XcodeAppInstanceInspector { + public enum WorkspaceIdentifier: Hashable { + case url(URL) + case unknown + } + + public class Workspace { + public let element: AXUIElement + public var info: WorkspaceInfo + + /// When a window is closed, all it's properties will be set to nil. + /// Since we can't get notification for window closing, + /// we will use it to check if the window is closed. + var isValid: Bool { + element.parent != nil + } + + init(element: AXUIElement) { + self.element = element + info = .init(tabs: []) + } + } + + public struct WorkspaceInfo { + public let tabs: Set + + public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { + return .init(tabs: tabs.union(info.tabs)) + } + } + + func updateWorkspaceInfo() { + let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) + workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) + } + + /// Use the project path as the workspace identifier. + static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { + if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { + return WorkspaceIdentifier.url(url) + } + return WorkspaceIdentifier.unknown + } + + /// With Accessibility API, we can ONLY get the information of visible windows. + static func fetchVisibleWorkspaces( + _ app: NSRunningApplication + ) -> [WorkspaceIdentifier: Workspace] { + let app = AXUIElementCreateApplication(app.processIdentifier) + let windows = app.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + var dict = [WorkspaceIdentifier: Workspace]() + + for window in windows { + let workspaceIdentifier = workspaceIdentifier(window) + + 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" } + for tabBar in tabBars { + let tabs = tabBar.children { $0.roleDescription == "tab" } + for tab in tabs { + allTabs.insert(tab.title) + } + } + return allTabs + }() + + let workspace = Workspace(element: window) + workspace.info = .init(tabs: tabs) + dict[workspaceIdentifier] = workspace + } + return dict + } + + static func updateWorkspace( + _ old: [WorkspaceIdentifier: Workspace], + with new: [WorkspaceIdentifier: Workspace] + ) -> [WorkspaceIdentifier: Workspace] { + var updated = old.filter { $0.value.isValid } // remove closed windows. + for (identifier, workspace) in new { + if let existed = updated[identifier] { + existed.info = workspace.info + } else { + updated[identifier] = workspace + } + } + return updated + } +} + diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift index f831d15a..eab2b002 100644 --- a/Tool/Sources/XcodeInspector/Helpers.swift +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -1,14 +1,14 @@ import AppKit import Foundation -extension NSRunningApplication { +public extension NSRunningApplication { var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" } var isCopilotForXcodeExtensionService: Bool { bundleIdentifier == Bundle.main.bundleIdentifier } } -extension FileManager { +public extension FileManager { func fileIsDirectory(atPath path: String) -> Bool { var isDirectory: ObjCBool = false let exists = fileExists(atPath: path, isDirectory: &isDirectory) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift new file mode 100644 index 00000000..e8703f2b --- /dev/null +++ b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift @@ -0,0 +1,142 @@ +import AppKit +import AXExtension +import Foundation +import Logger + +public extension XcodeAppInstanceInspector { + func triggerCopilotCommand(name: String, activateXcode: Bool = true) async throws { + let bundleName = Bundle.main + .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String + try await triggerMenuItem(path: ["Editor", bundleName, name], activateXcode: activateXcode) + } +} + +public extension AppInstanceInspector { + @MainActor + func triggerMenuItem(path: [String], activateXcode: Bool) async throws { + guard !path.isEmpty else { return } + + struct CantRunCommand: Error, LocalizedError { + let path: [String] + var errorDescription: String? { + "Can't run command \(path.joined(separator: "/"))." + } + } + + if activateXcode { + if !runningApplication.activate() { + throw CantRunCommand(path: path) + } + } else { + if !runningApplication.isActive { + throw CantRunCommand(path: path) + } + } + + await Task.yield() + + if UserDefaults.shared.value(for: \.triggerActionWithAccessibilityAPI) { + let app = AXUIElementCreateApplication(runningApplication.processIdentifier) + guard let menuBar = app.menuBar else { throw CantRunCommand(path: path) } + var path = path + var currentMenu = menuBar + while !path.isEmpty { + let item = path.removeFirst() + + if path.isEmpty, let button = currentMenu.child(title: item, role: "AXMenuItem") { + let error = AXUIElementPerformAction(button, kAXPressAction as CFString) + if error != AXError.success { + Logger.service.error(""" + Trigger menu item \(path.joined(separator: "/")) failed: \ + \(error.localizedDescription) + """) + throw error + } else { + return + } + } else if let menu = currentMenu.child(title: item) { + currentMenu = menu + } else { + throw CantRunCommand(path: path) + } + } + } else { + guard path.count >= 2 else { throw CantRunCommand(path: path) } + + let clickTask = { + var path = path + let button = path.removeLast() + let menuBarItem = path.removeFirst() + let list = path + .reversed() + .map { "menu 1 of menu item \"\($0)\"" } + .joined(separator: " of ") + return """ + click menu item "\(button)" of \(list) \ + of menu bar item "\(menuBarItem)" \ + of menu bar 1 + """ + }() + /// check if menu is open, if not, click the menu item. + let appleScript = """ + tell application "System Events" + set theprocs to every process whose unix id is \ + \(runningApplication.processIdentifier) + repeat with proc in theprocs + tell proc + repeat with theMenu in menus of menu bar 1 + set theValue to value of attribute "AXVisibleChildren" of theMenu + if theValue is not {} then + return + end if + end repeat + \(clickTask) + end tell + end repeat + end tell + """ + + do { + try await runAppleScript(appleScript) + } catch { + Logger.service.error(""" + Trigger menu item \(path.joined(separator: "/")) failed: \ + \(error.localizedDescription) + """) + throw error + } + } + } +} + +@discardableResult +func runAppleScript(_ appleScript: String) async throws -> String { + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", appleScript] + let outpipe = Pipe() + task.standardOutput = outpipe + task.standardError = Pipe() + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { _ in + do { + if let data = try outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(returning: content) + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d3def46b..917b6f4f 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -4,6 +4,8 @@ import AXExtension import AXNotificationStream import Combine import Foundation +import Logger +import Preferences import SuggestionModel public final class XcodeInspector: ObservableObject { @@ -11,20 +13,21 @@ public final class XcodeInspector: ObservableObject { private var cancellable = Set() private var activeXcodeObservations = Set>() + private var appChangeObservations = Set>() private var activeXcodeCancellable = Set() - @Published public internal(set) var activeApplication: AppInstanceInspector? - @Published public internal(set) var previousActiveApplication: AppInstanceInspector? - @Published public internal(set) var activeXcode: XcodeAppInstanceInspector? - @Published public internal(set) var latestActiveXcode: XcodeAppInstanceInspector? - @Published public internal(set) var xcodes: [XcodeAppInstanceInspector] = [] - @Published public internal(set) var activeProjectRootURL: URL? = nil - @Published public internal(set) var activeDocumentURL: URL? = nil - @Published public internal(set) var activeWorkspaceURL: URL? = nil - @Published public internal(set) var focusedWindow: XcodeWindowInspector? - @Published public internal(set) var focusedEditor: SourceEditor? - @Published public internal(set) var focusedElement: AXUIElement? - @Published public internal(set) var completionPanel: AXUIElement? + @Published public fileprivate(set) var activeApplication: AppInstanceInspector? + @Published public fileprivate(set) var previousActiveApplication: AppInstanceInspector? + @Published public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? + @Published public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? + @Published public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] + @Published public fileprivate(set) var activeProjectRootURL: URL? = nil + @Published public fileprivate(set) var activeDocumentURL: URL? = nil + @Published public fileprivate(set) var activeWorkspaceURL: URL? = nil + @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? + @Published public fileprivate(set) var focusedEditor: SourceEditor? + @Published public fileprivate(set) var focusedElement: AXUIElement? + @Published public fileprivate(set) var completionPanel: AXUIElement? public var focusedEditorContent: EditorInformation? { guard let documentURL = XcodeInspector.shared.realtimeActiveDocumentURL, @@ -74,10 +77,31 @@ public final class XcodeInspector: ObservableObject { } public var realtimeActiveProjectURL: URL? { - latestActiveXcode?.realtimeProjectURL ?? activeWorkspaceURL + latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL } init() { + restart() + } + + public func restart(cleanUp: Bool = false) { + if cleanUp { + activeXcodeObservations.forEach { $0.cancel() } + activeXcodeObservations.removeAll() + activeXcodeCancellable.forEach { $0.cancel() } + activeXcodeCancellable.removeAll() + activeXcode = nil + latestActiveXcode = nil + activeApplication = nil + activeProjectRootURL = nil + activeDocumentURL = nil + activeWorkspaceURL = nil + focusedWindow = nil + focusedEditor = nil + focusedElement = nil + completionPanel = nil + } + let runningApplications = NSWorkspace.shared.runningApplications xcodes = runningApplications .filter { $0.isXcode } @@ -87,74 +111,85 @@ public final class XcodeInspector: ObservableObject { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) + + appChangeObservations.forEach { $0.cancel() } + appChangeObservations.removeAll() - #warning("Test Me") - - Task { // Did activate app + let appChangeTask = Task { [weak self] in + guard let self else { return } if let activeXcode { await setActiveXcode(activeXcode) } - - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.didActivateApplicationNotification) - for await notification in sequence { - try Task.checkCancellation() - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { continue } - if app.isXcode { - if let existed = xcodes.first(where: { - $0.runningApplication.processIdentifier == app.processIdentifier - }) { - await MainActor.run { - setActiveXcode(existed) - } - } else { - let new = XcodeAppInstanceInspector(runningApplication: app) - await MainActor.run { - xcodes.append(new) - setActiveXcode(new) + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in // Did activate app + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + if app.isXcode { + if let existed = xcodes.first(where: { + $0.runningApplication.processIdentifier == app.processIdentifier + }) { + await MainActor.run { + self.setActiveXcode(existed) + } + } else { + let new = XcodeAppInstanceInspector(runningApplication: app) + await MainActor.run { + self.xcodes.append(new) + self.setActiveXcode(new) + } + } + } else { + let appInspector = AppInstanceInspector(runningApplication: app) + await MainActor.run { + self.previousActiveApplication = self.activeApplication + self.activeApplication = appInspector + } } } - } else { - let appInspector = AppInstanceInspector(runningApplication: app) - await MainActor.run { - previousActiveApplication = activeApplication - activeApplication = appInspector - } } - } - } - - Task { // Did terminate app - let sequence = NSWorkspace.shared.notificationCenter - .notifications(named: NSWorkspace.didTerminateApplicationNotification) - for await notification in sequence { - try Task.checkCancellation() - guard let app = notification - .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication - else { continue } - if app.isXcode { - let processIdentifier = app.processIdentifier - await MainActor.run { - xcodes.removeAll { - $0.runningApplication.processIdentifier == processIdentifier - } - if latestActiveXcode?.runningApplication - .processIdentifier == processIdentifier - { - latestActiveXcode = nil - } - - if let activeXcode = xcodes.first(where: \.isActive) { - setActiveXcode(activeXcode) + + group.addTask { [weak self] in // Did terminate app + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didTerminateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + if app.isXcode { + let processIdentifier = app.processIdentifier + await MainActor.run { + self.xcodes.removeAll { + $0.runningApplication.processIdentifier == processIdentifier + } + if self.latestActiveXcode?.runningApplication + .processIdentifier == processIdentifier + { + self.latestActiveXcode = nil + } + + if let activeXcode = self.xcodes.first(where: \.isActive) { + self.setActiveXcode(activeXcode) + } + } } } } } } + + appChangeObservations.insert(appChangeTask) } + @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication @@ -208,341 +243,25 @@ public final class XcodeInspector: ObservableObject { activeXcodeObservations.insert(focusedElementChanged) - xcode.$completionPanel.sink { [weak self] element in + xcode.$completionPanel.receive(on: DispatchQueue.main).sink { [weak self] element in self?.completionPanel = element }.store(in: &activeXcodeCancellable) - xcode.$documentURL.sink { [weak self] url in + xcode.$documentURL.receive(on: DispatchQueue.main).sink { [weak self] url in self?.activeDocumentURL = url }.store(in: &activeXcodeCancellable) - xcode.$workspaceURL.sink { [weak self] url in + xcode.$workspaceURL.receive(on: DispatchQueue.main).sink { [weak self] url in self?.activeWorkspaceURL = url }.store(in: &activeXcodeCancellable) - xcode.$projectRootURL.sink { [weak self] url in + xcode.$projectRootURL.receive(on: DispatchQueue.main).sink { [weak self] url in self?.activeProjectRootURL = url }.store(in: &activeXcodeCancellable) - xcode.$focusedWindow.sink { [weak self] window in + xcode.$focusedWindow.receive(on: DispatchQueue.main).sink { [weak self] window in self?.focusedWindow = window }.store(in: &activeXcodeCancellable) } } -// MARK: - AppInstanceInspector - -public class AppInstanceInspector: ObservableObject { - public let appElement: AXUIElement - public let runningApplication: NSRunningApplication - public var isActive: Bool { runningApplication.isActive } - public var isXcode: Bool { runningApplication.isXcode } - public var isExtensionService: Bool { runningApplication.isCopilotForXcodeExtensionService } - - init(runningApplication: NSRunningApplication) { - self.runningApplication = runningApplication - appElement = AXUIElementCreateApplication(runningApplication.processIdentifier) - } -} - -// MARK: - XcodeAppInstanceInspector - -public final class XcodeAppInstanceInspector: AppInstanceInspector { - @Published public var focusedWindow: XcodeWindowInspector? - @Published public var documentURL: URL? = nil - @Published public var workspaceURL: URL? = nil - @Published public var projectRootURL: URL? = nil - @Published public var workspaces = [WorkspaceIdentifier: Workspace]() - public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { - updateWorkspaceInfo() - return workspaces.mapValues(\.info) - } - - @Published public private(set) var completionPanel: AXUIElement? - - public var realtimeDocumentURL: URL? { - guard let window = appElement.focusedWindow, - window.identifier == "Xcode.WorkspaceWindow" - else { return nil } - - return WorkspaceXcodeWindowInspector.extractDocumentURL(windowElement: window) - } - - public var realtimeWorkspaceURL: URL? { - guard let window = appElement.focusedWindow, - window.identifier == "Xcode.WorkspaceWindow" - else { return nil } - - return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) - } - - public var realtimeProjectURL: URL? { - let workspaceURL = realtimeWorkspaceURL - let documentURL = realtimeDocumentURL - return WorkspaceXcodeWindowInspector.extractProjectURL( - workspaceURL: workspaceURL, - documentURL: documentURL - ) - } - - var _version: String? - public var version: String? { - if let _version { return _version } - guard let plistPath = runningApplication.bundleURL? - .appendingPathComponent("Contents") - .appendingPathComponent("version.plist") - .path - else { return nil } - guard let plistData = FileManager.default.contents(atPath: plistPath) else { return nil } - var format = PropertyListSerialization.PropertyListFormat.xml - guard let plistDict = try? PropertyListSerialization.propertyList( - from: plistData, - options: .mutableContainersAndLeaves, - format: &format - ) as? [String: AnyObject] else { return nil } - let result = plistDict["CFBundleShortVersionString"] as? String - _version = result - return result - } - - private var longRunningTasks = Set>() - private var focusedWindowObservations = Set() - - deinit { - for task in longRunningTasks { task.cancel() } - } - - override init(runningApplication: NSRunningApplication) { - super.init(runningApplication: runningApplication) - - observeFocusedWindow() - observeAXNotifications() - - Task { - try await Task.sleep(nanoseconds: 3_000_000_000) - // Sometimes the focused window may not be ready on app launch. - if !(focusedWindow is WorkspaceXcodeWindowInspector) { - observeFocusedWindow() - } - } - } - - func observeFocusedWindow() { - if let window = appElement.focusedWindow { - if window.identifier == "Xcode.WorkspaceWindow" { - let window = WorkspaceXcodeWindowInspector( - app: runningApplication, - uiElement: window - ) - focusedWindow = window - - // should find a better solution to do this thread safe - Task { @MainActor in - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() - - documentURL = window.documentURL - workspaceURL = window.workspaceURL - projectRootURL = window.projectRootURL - - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$workspaceURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.workspaceURL = url - }.store(in: &focusedWindowObservations) - window.$projectRootURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.projectRootURL = url - }.store(in: &focusedWindowObservations) - } - } else { - let window = XcodeWindowInspector(uiElement: window) - focusedWindow = window - } - } else { - focusedWindow = nil - } - } - - func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() - } - } - - func observeAXNotifications() { - longRunningTasks.forEach { $0.cancel() } - longRunningTasks = [] - - let focusedWindowChanged = Task { - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedWindowChangedNotification - ) - for await _ in notification { - try Task.checkCancellation() - observeFocusedWindow() - } - } - - longRunningTasks.insert(focusedWindowChanged) - - updateWorkspaceInfo() - let updateTabsTask = Task { @MainActor in - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXApplicationDeactivatedNotification - ) - if #available(macOS 13.0, *) { - for await _ in notification.debounce(for: .seconds(2)) { - try Task.checkCancellation() - updateWorkspaceInfo() - } - } else { - for await _ in notification { - try Task.checkCancellation() - updateWorkspaceInfo() - } - } - } - - longRunningTasks.insert(updateTabsTask) - - let completionPanelTask = Task { - let stream = AXNotificationStream( - app: runningApplication, - notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification - ) - - for await event in stream { - // We can only observe the creation and closing of the parent - // of the completion panel. - let isCompletionPanel = { - event.element.firstChild { element in - element.identifier == "_XC_COMPLETION_TABLE_" - } != nil - } - switch event.name { - case kAXCreatedNotification: - if isCompletionPanel() { - completionPanel = event.element - } - case kAXUIElementDestroyedNotification: - if isCompletionPanel() { - completionPanel = nil - } - default: break - } - - try Task.checkCancellation() - } - } - - longRunningTasks.insert(completionPanelTask) - } -} - -// MARK: - Workspace Info - -extension XcodeAppInstanceInspector { - public enum WorkspaceIdentifier: Hashable { - case url(URL) - case unknown - } - - public class Workspace { - public let element: AXUIElement - public var info: WorkspaceInfo - - /// When a window is closed, all it's properties will be set to nil. - /// Since we can't get notification for window closing, - /// we will use it to check if the window is closed. - var isValid: Bool { - element.parent != nil - } - - init(element: AXUIElement) { - self.element = element - info = .init(tabs: []) - } - } - - public struct WorkspaceInfo { - public let tabs: Set - - public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { - return .init(tabs: tabs.union(info.tabs)) - } - } - - func updateWorkspaceInfo() { - let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) - workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) - } - - /// Use the project path as the workspace identifier. - static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { - if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { - return WorkspaceIdentifier.url(url) - } - return WorkspaceIdentifier.unknown - } - - /// With Accessibility API, we can ONLY get the information of visible windows. - static func fetchVisibleWorkspaces( - _ app: NSRunningApplication - ) -> [WorkspaceIdentifier: Workspace] { - let app = AXUIElementCreateApplication(app.processIdentifier) - let windows = app.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } - - var dict = [WorkspaceIdentifier: Workspace]() - - for window in windows { - let workspaceIdentifier = workspaceIdentifier(window) - - 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" } - for tabBar in tabBars { - let tabs = tabBar.children { $0.roleDescription == "tab" } - for tab in tabs { - allTabs.insert(tab.title) - } - } - return allTabs - }() - - let workspace = Workspace(element: window) - workspace.info = .init(tabs: tabs) - dict[workspaceIdentifier] = workspace - } - return dict - } - - static func updateWorkspace( - _ old: [WorkspaceIdentifier: Workspace], - with new: [WorkspaceIdentifier: Workspace] - ) -> [WorkspaceIdentifier: Workspace] { - var updated = old.filter { $0.value.isValid } // remove closed windows. - for (identifier, workspace) in new { - if let existed = updated[identifier] { - existed.info = workspace.info - } else { - updated[identifier] = workspace - } - } - return updated - } -} - diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 95c9fb64..137fc434 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -5,7 +5,7 @@ import Combine import Foundation public class XcodeWindowInspector: ObservableObject { - let uiElement: AXUIElement + public let uiElement: AXUIElement init(uiElement: AXUIElement) { self.uiElement = uiElement @@ -17,11 +17,9 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { @Published var documentURL: URL = .init(fileURLWithPath: "/") @Published var workspaceURL: URL = .init(fileURLWithPath: "/") @Published var projectRootURL: URL = .init(fileURLWithPath: "/") - private var updateTabsTask: Task? private var focusedElementChangedTask: Task? deinit { - updateTabsTask?.cancel() focusedElementChangedTask?.cancel() } @@ -38,7 +36,6 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { notificationNames: kAXFocusedUIElementChangedNotification ) - #warning("Test Me") focusedElementChangedTask = Task { [weak self] in await self?.updateURLs() diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift index fa1d6711..20b2e768 100644 --- a/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift +++ b/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift @@ -17,8 +17,16 @@ final class RecursiveCharacterTextSplitterTests: XCTestCase { let result = try await splitter.split(text: text) XCTAssertEqual(result, [ - "Madam Speaker, Madam Vice President, our First Lady and Second Gentleman. Members of Congress and", - "of Congress and the Cabinet. Justices of the Supreme Court. My fellow Americans.", + .init( + text: "Madam Speaker, Madam Vice President, our First Lady and Second Gentleman. Members of Congress and", + startUTF16Offset: 0, + endUTF16Offset: 97 + ), + .init( + text: " of Congress and the Cabinet. Justices of the Supreme Court. My fellow Americans.", + startUTF16Offset: 81, + endUTF16Offset: 162 + ), ]) } @@ -65,14 +73,48 @@ final class RecursiveCharacterTextSplitterTests: XCTestCase { let result = try await splitter.split(text: code) XCTAssertEqual( result, - ["protocol Animal {\n var name: String { get }\n var legs: Int { get }\n func makeSound()\n}\n", - "\n@MainActor", - "\nprivate class Dog: Animal {\n var name: String\n var legs: Int\n init(name: String, legs:", - "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", - "func makeSound() {\n print(\"Woof!\")\n }\n}\n", - "\nfinal class Cat: Animal {\n var name: String\n var legs: Int\n init(name: String, legs: Int)", - "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", - "func makeSound() {\n print(\"Meow!\")\n }\n}"] + [ + .init( + text: "protocol Animal {\n var name: String { get }\n var legs: Int { get }\n func makeSound()\n}\n", + startUTF16Offset: 0, + endUTF16Offset: 96 + ), + .init( + text: "\n@MainActor", + startUTF16Offset: 96, + endUTF16Offset: 107 + ), + .init( + text: "\nprivate class Dog: Animal {\n var name: String\n var legs: Int\n init(name: String, legs:", + startUTF16Offset: 107, + endUTF16Offset: 203 + ), + .init( + text: " String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", + startUTF16Offset: 189, + endUTF16Offset: 287 + ), + .init( + text: " func makeSound() {\n print(\"Woof!\")\n }\n}\n", + startUTF16Offset:267, + endUTF16Offset: 321 + ), + .init( + text: "\nfinal class Cat: Animal {\n var name: String\n var legs: Int\n init(name: String, legs: Int)", + startUTF16Offset: 321, + endUTF16Offset: 420 + ), + .init( + text: " String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", + startUTF16Offset: 401, + endUTF16Offset: 499 + ), + .init( + text: " func makeSound() {\n print(\"Meow!\")\n }\n}", + startUTF16Offset: 479, + endUTF16Offset: 532 + ), + ] ) } } diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/TextChunkTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/TextChunkTests.swift new file mode 100644 index 00000000..7791ac07 --- /dev/null +++ b/Tool/Tests/LangChainTests/TextSplitterTests/TextChunkTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest + +@testable import LangChain + +class TextChunkTests: XCTestCase { + func test_merging_overlapping_text_chunks() { + let chunk1 = TextChunk(text: "abc", startUTF16Offset: 0, endUTF16Offset: 3) + let chunk2 = TextChunk(text: "cdef", startUTF16Offset: 2, endUTF16Offset: 6) + let mergedChunk = chunk1.merged(with: chunk2) + XCTAssertEqual(mergedChunk?.text, "abcdef") + XCTAssertEqual(mergedChunk?.startUTF16Offset, 0) + XCTAssertEqual(mergedChunk?.endUTF16Offset, 6) + } + + func test_merging_adjacent_text_chunks() { + let chunk1 = TextChunk(text: "abc", startUTF16Offset: 0, endUTF16Offset: 3) + let chunk2 = TextChunk(text: "def", startUTF16Offset: 3, endUTF16Offset: 6) + let mergedChunk = chunk1.merged(with: chunk2) + XCTAssertEqual(mergedChunk?.text, "abcdef") + XCTAssertEqual(mergedChunk?.startUTF16Offset, 0) + XCTAssertEqual(mergedChunk?.endUTF16Offset, 6) + } + + func test_merging_overlapping_text_chunks_reversed_order() { + let chunk1 = TextChunk(text: "abc", startUTF16Offset: 0, endUTF16Offset: 3) + let chunk2 = TextChunk(text: "cdef", startUTF16Offset: 2, endUTF16Offset: 6) + let mergedChunk = chunk2.merged(with: chunk1) + XCTAssertEqual(mergedChunk?.text, "abcdef") + XCTAssertEqual(mergedChunk?.startUTF16Offset, 0) + XCTAssertEqual(mergedChunk?.endUTF16Offset, 6) + } + + func test_merging_adjacent_text_chunks_reversed_order() { + let chunk1 = TextChunk(text: "abc", startUTF16Offset: 0, endUTF16Offset: 3) + let chunk2 = TextChunk(text: "def", startUTF16Offset: 3, endUTF16Offset: 6) + let mergedChunk = chunk2.merged(with: chunk1) + XCTAssertEqual(mergedChunk?.text, "abcdef") + XCTAssertEqual(mergedChunk?.startUTF16Offset, 0) + XCTAssertEqual(mergedChunk?.endUTF16Offset, 6) + } + + func test_do_not_merge_non_overlapping_text_chunks() { + let chunk1 = TextChunk(text: "abc", startUTF16Offset: 0, endUTF16Offset: 3) + let chunk2 = TextChunk(text: "def", startUTF16Offset: 4, endUTF16Offset: 7) + let mergedChunk = chunk1.merged(with: chunk2) + XCTAssertNil(mergedChunk) + } +} diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift index 13f2f88d..c3030617 100644 --- a/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift +++ b/Tool/Tests/LangChainTests/TextSplitterTests/TextSplitterTests.swift @@ -7,7 +7,7 @@ final class TextSplitterTests: XCTestCase { var chunkSize: Int var chunkOverlap: Int var lengthFunction: (String) -> Int = { $0.count } - func split(text: String) async throws -> [String] { + func split(text: String) async throws -> [TextChunk] { [] } } @@ -25,7 +25,15 @@ final class TextSplitterTests: XCTestCase { XCTAssertEqual( result, - ["Madam", " Speaker,", " Madam", " Vice", " President,", " our", " First"] + [ + .init(text: "Madam", startUTF16Offset: 0, endUTF16Offset: 5), + .init(text: " Speaker,", startUTF16Offset: 5, endUTF16Offset: 14), + .init(text: " Madam", startUTF16Offset: 14, endUTF16Offset: 20), + .init(text: " Vice", startUTF16Offset: 20, endUTF16Offset: 25), + .init(text: " President,", startUTF16Offset: 25, endUTF16Offset: 36), + .init(text: " our", startUTF16Offset: 36, endUTF16Offset: 40), + .init(text: " First", startUTF16Offset: 40, endUTF16Offset: 46), + ] ) } @@ -42,7 +50,10 @@ final class TextSplitterTests: XCTestCase { XCTAssertEqual( result, - ["Madam Speaker, Madam", " Vice President, our First"] + [ + .init(text: "Madam Speaker, Madam", startUTF16Offset: 0, endUTF16Offset: 20), + .init(text: " Vice President, our First", startUTF16Offset: 20, endUTF16Offset: 46), + ] ) } @@ -53,14 +64,27 @@ final class TextSplitterTests: XCTestCase { ) let result = splitter.mergeSplits( - ["Madam", " Speaker,", " Madam", " Vice", " President,", " our", " First"] + [ + .init(text: "Madam", startUTF16Offset: 0, endUTF16Offset: 5), + .init(text: " Speaker,", startUTF16Offset: 5, endUTF16Offset: 14), + .init(text: " Madam", startUTF16Offset: 14, endUTF16Offset: 20), + .init(text: " Vice", startUTF16Offset: 20, endUTF16Offset: 25), + .init(text: " President,", startUTF16Offset: 25, endUTF16Offset: 36), + .init(text: " our", startUTF16Offset: 36, endUTF16Offset: 40), + .init(text: " First", startUTF16Offset: 40, endUTF16Offset: 46), + ] ) XCTAssertEqual( result, - ["Madam Speaker,", "Madam Vice", "President, our", "our First"] + [ + .init(text: "Madam Speaker,", startUTF16Offset: 0, endUTF16Offset: 14), + .init(text: " Madam Vice", startUTF16Offset: 14, endUTF16Offset: 25), + .init(text: " President, our", startUTF16Offset: 25, endUTF16Offset: 40), + .init(text: " our First", startUTF16Offset: 36, endUTF16Offset: 46), + ] ) - XCTAssertTrue(result.allSatisfy { $0.count <= 15 }) + XCTAssertTrue(result.allSatisfy { $0.text.count <= 15 }) } } diff --git a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift new file mode 100644 index 00000000..8e1fb855 --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift @@ -0,0 +1,242 @@ +import Foundation +import XCTest + +@testable import OpenAIService + +class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { + let separator = String(repeating: "=", count: 32) + + func ref(_ text: String) -> ChatMessage.Reference { + .init( + title: "", + subTitle: "", + content: text, + uri: "", + startLine: nil, + endLine: nil, + kind: .text + ) + } + + func test_retrieved_content_when_the_context_window_is_large_enough() async { + let strategy = Strategy() + + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: UserPreferenceChatGPTConfiguration(), + functionProvider: EmptyFunctionProvider() + ) + + await memory.mutateRetrievedContent([ + ref("A"), ref("B"), ref("C"), ref("D"), ref("E"), + ]) + + let fullContent = """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + + \(separator)[DOCUMENT 1] + + B + + \(separator)[DOCUMENT 2] + + C + + \(separator)[DOCUMENT 3] + + D + + \(separator)[DOCUMENT 4] + + E + """ + + let maxTokenCount = await strategy.countToken(.init(role: .user, content: fullContent)) + + let result = await memory.generateRetrievedContentMessage( + maxTokenCount: maxTokenCount, + strategy: strategy + ) + + XCTAssertEqual(result.references.count, 5) + XCTAssertEqual(result.retrievedContent.role, .user) + XCTAssertEqual(result.retrievedContent.content, """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + + \(separator)[DOCUMENT 1] + + B + + \(separator)[DOCUMENT 2] + + C + + \(separator)[DOCUMENT 3] + + D + + \(separator)[DOCUMENT 4] + + E + """) + } + + func test_retrieved_content_when_the_context_window_is_just_not_large_enough() async { + let strategy = Strategy() + + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: UserPreferenceChatGPTConfiguration(), + functionProvider: EmptyFunctionProvider() + ) + + await memory.mutateRetrievedContent([ + ref("A"), ref("B"), ref("C"), ref("D"), ref("E"), + ]) + + let fullContent = """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + + \(separator)[DOCUMENT 1] + + B + + \(separator)[DOCUMENT 2] + + C + + \(separator)[DOCUMENT 3] + + D + + \(separator)[DOCUMENT 4] + + E + """ + + let maxTokenCount = await strategy.countToken(.init(role: .user, content: fullContent)) + + let result = await memory.generateRetrievedContentMessage( + maxTokenCount: maxTokenCount - 1, + strategy: strategy + ) + + XCTAssertEqual(result.references.count, 4) + XCTAssertEqual(result.retrievedContent.role, .user) + XCTAssertEqual(result.retrievedContent.content, """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + + \(separator)[DOCUMENT 1] + + B + + \(separator)[DOCUMENT 2] + + C + + \(separator)[DOCUMENT 3] + + D + """) + } + + func test_retrieved_content_when_the_context_window_can_take_only_one_document() async { + let strategy = Strategy() + + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: UserPreferenceChatGPTConfiguration(), + functionProvider: EmptyFunctionProvider() + ) + + await memory.mutateRetrievedContent([ + ref("A"), ref("B"), ref("C"), ref("D"), ref("E"), + ]) + + let fullContent = """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + """ + + let maxTokenCount = await strategy.countToken(.init(role: .user, content: fullContent)) + + let result = await memory.generateRetrievedContentMessage( + maxTokenCount: maxTokenCount + 1, + strategy: strategy + ) + + XCTAssertEqual(result.references.count, 1) + XCTAssertEqual(result.retrievedContent.role, .user) + XCTAssertEqual(result.retrievedContent.content, """ + Here are the information you know about the system and the project, \ + separated by \(separator) + + \(separator)[DOCUMENT 0] + + A + """) + } + + func test_retrieved_content_when_the_context_window_empty() async { + let strategy = Strategy() + + let memory = AutoManagedChatGPTMemory( + systemPrompt: "", + configuration: UserPreferenceChatGPTConfiguration(), + functionProvider: EmptyFunctionProvider() + ) + + await memory.mutateRetrievedContent([ + ref("A"), ref("B"), ref("C"), ref("D"), ref("E"), + ]) + + let result = await memory.generateRetrievedContentMessage( + maxTokenCount: 0, + strategy: strategy + ) + + XCTAssertEqual(result.references.count, 0) + XCTAssertEqual(result.retrievedContent.role, .user) + XCTAssertEqual(result.retrievedContent.content, "") + } +} + +private struct EmptyFunctionProvider: ChatGPTFunctionProvider { + var functions: [any ChatGPTFunction] { [] } + var functionCallStrategy: FunctionCallStrategy? { nil } +} + +private struct Strategy: AutoManagedChatGPTMemoryStrategy { + func countToken(_ message: OpenAIService.ChatMessage) async -> Int { + message.content?.count ?? 0 + } + + func countToken(_: F) async -> Int where F: ChatGPTFunction { + 0 + } +} + diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 685b412e..5349f85e 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -5,7 +5,9 @@ import XCTest final class ChatGPTStreamTests: XCTestCase { func test_sending_message() async throws { let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding() + let configuration = UserPreferenceChatGPTConfiguration().overriding { + $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) + } let functionProvider = NoChatGPTFunctionProvider() let service = ChatGPTService( memory: memory, @@ -13,7 +15,7 @@ final class ChatGPTStreamTests: XCTestCase { functionProvider: functionProvider ) var requestBody: CompletionRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody in + service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in requestBody = _requestBody return MockCompletionStreamAPI_Message() } @@ -64,7 +66,9 @@ final class ChatGPTStreamTests: XCTestCase { func test_handling_function_call() async throws { let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding() + let configuration = UserPreferenceChatGPTConfiguration().overriding { + $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) + } let functionProvider = FunctionProvider() let service = ChatGPTService( memory: memory, @@ -72,7 +76,7 @@ final class ChatGPTStreamTests: XCTestCase { functionProvider: functionProvider ) var requestBody: CompletionRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody in + service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in requestBody = _requestBody if _requestBody.messages.count <= 2 { return MockCompletionStreamAPI_Function() @@ -95,7 +99,7 @@ final class ChatGPTStreamTests: XCTestCase { "History is not updated" ) } - + XCTAssertEqual(requestBody?.messages, [ .init(role: .system, content: "system"), .init(role: .user, content: "Hello"), @@ -105,9 +109,9 @@ final class ChatGPTStreamTests: XCTestCase { ), .init(role: .function, content: "Function is called.", name: "function"), ], "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 @@ -128,9 +132,13 @@ final class ChatGPTStreamTests: XCTestCase { name: "function", summary: nil ), - .init(id: "00000000-0000-0000-0000-0000000000040.0", role: .assistant, content: "hellomyfriends"), + .init( + id: "00000000-0000-0000-0000-0000000000040.0", + role: .assistant, + content: "hellomyfriends" + ), ], "History is not updated") - + XCTAssertEqual(requestBody?.functions, [ EmptyFunction(), ].map { @@ -141,7 +149,9 @@ final class ChatGPTStreamTests: XCTestCase { func test_handling_multiple_function_call() async throws { let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s") - let configuration = UserPreferenceChatGPTConfiguration().overriding() + let configuration = UserPreferenceChatGPTConfiguration().overriding { + $0.model = .init(id: "id", name: "name", format: .openAI, info: .init()) + } let functionProvider = FunctionProvider() let service = ChatGPTService( memory: memory, @@ -150,7 +160,7 @@ final class ChatGPTStreamTests: XCTestCase { ) var requestBody: CompletionRequestBody? - service.changeBuildCompletionStreamAPI { _, _, _, _requestBody in + service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in requestBody = _requestBody if _requestBody.messages.count <= 4 { return MockCompletionStreamAPI_Function() @@ -173,7 +183,7 @@ final class ChatGPTStreamTests: XCTestCase { "History is not updated" ) } - + XCTAssertEqual(requestBody?.messages, [ .init(role: .system, content: "system"), .init(role: .user, content: "Hello"), @@ -188,9 +198,9 @@ final class ChatGPTStreamTests: XCTestCase { ), .init(role: .function, content: "Function is called.", name: "function"), ], "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 @@ -224,9 +234,13 @@ final class ChatGPTStreamTests: XCTestCase { name: "function", summary: nil ), - .init(id: "00000000-0000-0000-0000-0000000000070.0", role: .assistant, content: "hellomyfriends"), + .init( + id: "00000000-0000-0000-0000-0000000000070.0", + role: .assistant, + content: "hellomyfriends" + ), ], "History is not updated") - + XCTAssertEqual(requestBody?.functions, [ EmptyFunction(), ].map { @@ -234,96 +248,172 @@ final class ChatGPTStreamTests: XCTestCase { }, "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 = ChatGPTService( + memory: memory, + configuration: configuration, + functionProvider: functionProvider + ) + var requestBody: CompletionRequestBody? + 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-0000000000040.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: "", + function_call: .init(name: "function", arguments: "{\n\"foo\": 1\n}") + ), + .init(role: .function, content: "Function is called.", name: "function"), + ], "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, + functionCall: .init(name: "function", arguments: "{\n\"foo\": 1\n}") + ), + .init( + id: "00000000-0000-0000-0000-000000000003", + role: .function, + content: "Function is called.", + name: "function", + summary: nil + ), + .init( + id: "00000000-0000-0000-0000-0000000000040.0", + role: .assistant, + content: "hellomyfriends" + ), + ], "History is not updated") + + XCTAssertEqual(requestBody?.functions, nil, "Functions should be nil") + } + } } extension ChatGPTStreamTests { struct MockCompletionStreamAPI_Message: CompletionStreamAPI { @Dependency(\.uuid) var uuid - func callAsFunction() async throws -> ( - chunkStream: AsyncThrowingStream, - cancel: OpenAIService.Cancellable - ) { + func callAsFunction() async throws + -> AsyncThrowingStream + { let id = uuid().uuidString - return ( - AsyncThrowingStream { continuation in - let chunks: [CompletionStreamDataChunk] = [ - .init(id: id, object: "", model: "", choices: [ - .init(delta: .init(role: .assistant), index: 0, finish_reason: ""), - ]), - .init(id: id, object: "", model: "", choices: [ - .init(delta: .init(content: "hello"), index: 0, finish_reason: ""), - ]), - .init(id: id, object: "", model: "", choices: [ - .init(delta: .init(content: "my"), index: 0, finish_reason: ""), - ]), - .init(id: id, object: "", model: "", choices: [ - .init(delta: .init(content: "friends"), index: 0, finish_reason: ""), - ]), - ] - for chunk in chunks { - continuation.yield(chunk) - } - continuation.finish() - }, - Cancellable(cancel: {}) - ) + return AsyncThrowingStream { continuation in + let chunks: [CompletionStreamDataChunk] = [ + .init(id: id, object: "", model: "", choices: [ + .init(delta: .init(role: .assistant), index: 0, finish_reason: ""), + ]), + .init(id: id, object: "", model: "", choices: [ + .init(delta: .init(content: "hello"), index: 0, finish_reason: ""), + ]), + .init(id: id, object: "", model: "", choices: [ + .init(delta: .init(content: "my"), index: 0, finish_reason: ""), + ]), + .init(id: id, object: "", model: "", choices: [ + .init(delta: .init(content: "friends"), index: 0, finish_reason: ""), + ]), + ] + for chunk in chunks { + continuation.yield(chunk) + } + continuation.finish() + } } } struct MockCompletionStreamAPI_Function: CompletionStreamAPI { @Dependency(\.uuid) var uuid - func callAsFunction() async throws -> ( - chunkStream: AsyncThrowingStream, - cancel: OpenAIService.Cancellable - ) { + func callAsFunction() async throws + -> AsyncThrowingStream + { let id = uuid().uuidString - return ( - AsyncThrowingStream { continuation in - let chunks: [CompletionStreamDataChunk] = [ - .init(id: id, object: "", model: "", choices: [ - .init( - delta: .init( - role: .assistant, - function_call: .init(name: "function", arguments: "") - ), - index: 0, - finish_reason: "" - )]), - .init(id: id, object: "", model: "", choices: [ - .init( - delta: .init( - role: .assistant, - function_call: .init(arguments: "{\n") - ), - index: 0, - finish_reason: "" - )]), - .init(id: id, object: "", model: "", choices: [ - .init( - delta: .init( - role: .assistant, - function_call: .init(arguments: "\"foo\": 1") - ), - index: 0, - finish_reason: "" - )]), - .init(id: id, object: "", model: "", choices: [ - .init( - delta: .init( - role: .assistant, - function_call: .init(arguments: "\n}") - ), - index: 0, - finish_reason: "" - )]), - ] - for chunk in chunks { - continuation.yield(chunk) - } - continuation.finish() - }, - Cancellable(cancel: {}) - ) + return AsyncThrowingStream { continuation in + let chunks: [CompletionStreamDataChunk] = [ + .init(id: id, object: "", model: "", choices: [ + .init( + delta: .init( + role: .assistant, + function_call: .init(name: "function", arguments: "") + ), + index: 0, + finish_reason: "" + )]), + .init(id: id, object: "", model: "", choices: [ + .init( + delta: .init( + role: .assistant, + function_call: .init(arguments: "{\n") + ), + index: 0, + finish_reason: "" + )]), + .init(id: id, object: "", model: "", choices: [ + .init( + delta: .init( + role: .assistant, + function_call: .init(arguments: "\"foo\": 1") + ), + index: 0, + finish_reason: "" + )]), + .init(id: id, object: "", model: "", choices: [ + .init( + delta: .init( + role: .assistant, + function_call: .init(arguments: "\n}") + ), + index: 0, + finish_reason: "" + )]), + ] + for chunk in chunks { + continuation.yield(chunk) + } + continuation.finish() + } } } diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 30e4f3a4..1173494a 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -4,14 +4,17 @@ import XCTest @testable import OpenAIService -final class AutoManagedChatGPTMemoryTests: XCTestCase { +final class AutoManagedChatGPTMemoryLimitTests: XCTestCase { func test_send_all_messages_if_not_reached_token_limit() async { let (messages, memory) = await runService( - systemPrompt: "system", messages: [ + systemPrompt: "system", + messages: [ "hi", "hello", "world", - ], maxTokens: 10000, minimumReplyTokens: 200, + ], + maxTokens: 10000, + minimumReplyTokens: 200, maxNumberOfMessages: 0 // smaller than 1 means no limit ) XCTAssertEqual(messages, [ @@ -23,20 +26,25 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { // XCTAssertEqual(remainingTokens, 10000 - 12 - 6) let history = await memory.history - XCTAssertEqual(history.map(\.tokensCount), [ - 5, - 8, - 8, - ]) + +// token count caching is removed +// XCTAssertEqual(history.map(\.tokensCount), [ +// 5, +// 8, +// 8, +// ]) } func test_send_max_message_if_not_reached_token_limit() async { let (messages, _) = await runService( - systemPrompt: "system", messages: [ + systemPrompt: "system", + messages: [ "hi", "hello", "world", - ], maxTokens: 10000, minimumReplyTokens: 200, + ], + maxTokens: 10000, + minimumReplyTokens: 200, maxNumberOfMessages: 2 ) XCTAssertEqual(messages, [ @@ -50,11 +58,14 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { func test_reached_token_limit() async { let (messages, _) = await runService( - systemPrompt: "system", messages: [ + systemPrompt: "system", + messages: [ "hi", "hello", "world", - ], maxTokens: 212, minimumReplyTokens: 200, + ], + maxTokens: 212, + minimumReplyTokens: 200, maxNumberOfMessages: 100 ) XCTAssertEqual(messages, [ @@ -66,12 +77,14 @@ final class AutoManagedChatGPTMemoryTests: XCTestCase { func test_minimum_reply_tokens_count() async { let (messages, _) = await runService( - systemPrompt: "system", messages: [ + systemPrompt: "system", + messages: [ "hi", "hello", "world", ], - maxTokens: 200, minimumReplyTokens: 200, + maxTokens: 200, + minimumReplyTokens: 200, maxNumberOfMessages: 100 ) XCTAssertEqual(messages, [ @@ -88,6 +101,21 @@ class MockEncoder: TokenEncoder { } } +struct MockStrategy: AutoManagedChatGPTMemoryStrategy { + let encoder = MockEncoder() + func countToken(_ message: OpenAIService.ChatMessage) async -> Int { + await encoder.countToken(message) + } + + func countToken(_: F) async -> Int where F: OpenAIService.ChatGPTFunction { + 0 + } + + func reformat(_ prompt: OpenAIService.ChatGPTPrompt) async -> OpenAIService.ChatGPTPrompt { + prompt + } +} + private func runService( systemPrompt: String, messages: [String], @@ -111,7 +139,7 @@ private func runService( let messages = await memory.generateSendingHistory( maxNumberOfMessages: maxNumberOfMessages, - encoder: MockEncoder() + strategy: MockStrategy() ) let contents = messages.history.map { $0.content ?? "" } diff --git a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift b/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift new file mode 100644 index 00000000..113da14a --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift @@ -0,0 +1,158 @@ +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, + functionCall: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }") + ), + .init(role: .assistant, content: "Merge me"), + .init(role: .function, content: "42ms", name: "ping"), + .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" } + + ====== + + Merge me + """), + .init(role: .user, content: """ + Result of ping: 42ms + + ====== + + 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)) + } +} + diff --git a/Version.xcconfig b/Version.xcconfig index 4313906d..675f7aaa 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.29.1 -APP_BUILD = 301 +APP_VERSION = 0.30.0 +APP_BUILD = 311 diff --git a/appcast.xml b/appcast.xml index 0c798d61..127ace1b 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.30.0 + Mon, 22 Jan 2024 16:01:13 +0800 + 311 + 0.30.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.30.0 + + + + 0.29.1 Tue, 16 Jan 2024 01:12:50 +0800