From 2f97bd4ba204a0fd667cb29e971eb270f55ce60c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 15:40:39 +0800 Subject: [PATCH 01/57] Update SwiftSyntax to 509.0.2 --- Tool/Package.swift | 2 +- .../Swift/SwiftFocusedCodeFinder.swift | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Tool/Package.swift b/Tool/Package.swift index 7bf67804..00c73f70 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -60,7 +60,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"), diff --git a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift index 3eb7a91f..24131550 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 ( @@ -71,7 +71,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 +84,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 +97,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 +110,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 +123,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 +135,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 +161,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) } @@ -293,15 +293,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 +322,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 +352,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 +370,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: ", ") ?? "" } From c23d06f99f73d646abdb5640891b91f65cf0f14c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 15:43:33 +0800 Subject: [PATCH 02/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 51539dcb..060dfe5f 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 51539dcbe8810b8409a51764e3fb458296cf1d01 +Subproject commit 060dfe5fc96d75122a4dd4fb32369653979abed9 From 0540c2b5babb6b322e56f8a45f031df130d7dfe8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 16:15:11 +0800 Subject: [PATCH 03/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 060dfe5f..e74e5cdd 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 060dfe5fc96d75122a4dd4fb32369653979abed9 +Subproject commit e74e5cdd8180eb6981a3eb600cb1d11f271cf387 From 3bbaf83af5fd59c3e1648207f2bb2831866112a8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 16:15:45 +0800 Subject: [PATCH 04/57] Update to only use closure and case as focused range when it's more than 80 lines --- .../KnownLanguageFocusedCodeFinder.swift | 17 +++++++++++++---- .../ObjectiveC/ObjectiveCCodeFinder.swift | 3 ++- .../Swift/SwiftFocusedCodeFinder.swift | 13 +++++++++---- .../SuggestionModel/ExportedFromLSP.swift | 5 +++++ 4 files changed, 29 insertions(+), 9 deletions(-) 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 24131550..368cb015 100644 --- a/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/Swift/SwiftFocusedCodeFinder.swift @@ -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) @@ -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: 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 } From 1a4d5b1818a9498de6a7f9f8c95f7b339a8e3fba Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 19:22:24 +0800 Subject: [PATCH 05/57] Move command trigger to XcodeInspector --- .../RealtimeSuggestionController.swift | 7 +- .../PseudoCommandHandler.swift | 9 +- Tool/Sources/Environment/Environment.swift | 126 ----------------- .../XcodeInspector/XcodeInspector.swift | 133 +++++++++++++++++- .../XcodeInspector/XcodeWindowInspector.swift | 1 - 5 files changed, 141 insertions(+), 135 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 2b529c2b..69e879b2 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -21,7 +21,7 @@ public actor RealtimeSuggestionController { private var sourceEditor: SourceEditor? init() {} - + deinit { task?.cancel() inflightPrefetchTask?.cancel() @@ -34,7 +34,7 @@ public actor RealtimeSuggestionController { func start() { Task { await observeXcodeChange() } } - + private func observeXcodeChange() { task?.cancel() task = Task { [weak self] in @@ -150,7 +150,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 diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f359ebff..d9808fa1 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -145,7 +145,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 +168,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 +208,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 } diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift index 445159de..eb4f2ede 100644 --- a/Tool/Sources/Environment/Environment.swift +++ b/Tool/Sources/Environment/Environment.swift @@ -25,24 +25,6 @@ public struct FailedToFetchFileURLError: Error, LocalizedError { 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 @@ -148,114 +130,6 @@ public enum Environment { 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 { diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d3def46b..2989d7ea 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 { @@ -88,8 +90,6 @@ public final class XcodeInspector: ObservableObject { .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - #warning("Test Me") - Task { // Did activate app if let activeXcode { await setActiveXcode(activeXcode) @@ -546,3 +546,132 @@ extension XcodeAppInstanceInspector { } } +// MARK: - Triggering Command + +public extension XcodeAppInstanceInspector { + func triggerCopilotCommand(name: String) async throws { + let bundleName = Bundle.main + .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String + try await triggerMenuItem(path: ["Editor", bundleName, name]) + } +} + +public extension AppInstanceInspector { + func triggerMenuItem(path: [String]) 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 !runningApplication.isActive { runningApplication.activate() } + + 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 + 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 + \(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/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 95c9fb64..d8858754 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -38,7 +38,6 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { notificationNames: kAXFocusedUIElementChangedNotification ) - #warning("Test Me") focusedElementChangedTask = Task { [weak self] in await self?.updateURLs() From 5d6bd737eecef2e5f3a3b131bd2138b5d65bed6a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 19:25:04 +0800 Subject: [PATCH 06/57] Remove unused content from Environment.swift --- Tool/Sources/Environment/Environment.swift | 23 ---------------------- 1 file changed, 23 deletions(-) diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift index eb4f2ede..abd35faf 100644 --- a/Tool/Sources/Environment/Environment.swift +++ b/Tool/Sources/Environment/Environment.swift @@ -107,29 +107,6 @@ public enum Environment { } 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 extension FileManager { From a377916acae94a504ef132440b8918a0a0b3f26f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:06:44 +0800 Subject: [PATCH 07/57] Fix unit tests --- .../ChatGPTStreamTests.swift | 272 ++++++++++++------ .../LimitMessagesTests.swift | 56 +++- 2 files changed, 223 insertions(+), 105 deletions(-) diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 685b412e..4157f325 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, @@ -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, @@ -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, @@ -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..0393805b 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -7,11 +7,14 @@ import XCTest final class AutoManagedChatGPTMemoryTests: 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 ?? "" } From 59418d2522044e81d78e25c9b633c19503387bcd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:09:35 +0800 Subject: [PATCH 08/57] Remove Environment.swift and it's package --- Core/Package.swift | 6 -- Tool/Package.swift | 12 --- Tool/Sources/Environment/Environment.swift | 119 --------------------- 3 files changed, 137 deletions(-) delete mode 100644 Tool/Sources/Environment/Environment.swift 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/Tool/Package.swift b/Tool/Package.swift index 00c73f70..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"]), @@ -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/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift deleted file mode 100644 index abd35faf..00000000 --- a/Tool/Sources/Environment/Environment.swift +++ /dev/null @@ -1,119 +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() } - - #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 extension FileManager { - func fileIsDirectory(atPath path: String) -> Bool { - var isDirectory: ObjCBool = false - let exists = fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue && exists - } -} - From d0e905d03f7cb647d881ba9bb1ab718f06ffabdc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:12:35 +0800 Subject: [PATCH 09/57] Remove useless imports of Environment --- Core/Sources/ChatPlugin/AITerminalChatPlugin.swift | 1 - Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift | 1 - .../Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift | 1 - .../ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift | 1 - .../ShortcutChatPlugin/ShortcutInputChatPlugin.swift | 1 - Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift | 1 - .../FeatureReducers/CircularWidgetFeature.swift | 1 - .../SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift | 1 - .../SuggestionWidget/FeatureReducers/SharedPanelFeature.swift | 1 - Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift | 1 - .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 1 - Core/Sources/SuggestionWidget/SharedPanelView.swift | 1 - Core/Sources/SuggestionWidget/SuggestionWidgetController.swift | 1 - Core/Sources/SuggestionWidget/WidgetView.swift | 1 - Core/Tests/ServiceTests/Environment.swift | 1 - ExtensionService/AppDelegate.swift | 1 - Pro | 2 +- Tool/Sources/Workspace/Filespace.swift | 1 - .../WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift | 1 - 19 files changed, 1 insertion(+), 19 deletions(-) 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/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..81489513 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 diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift index eeeddc0d..85c3c15a 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 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/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..164f4a07 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 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/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 15312c32..f81e101b 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 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.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 e74e5cdd..b49fbf24 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit e74e5cdd8180eb6981a3eb600cb1d11f271cf387 +Subproject commit b49fbf244ee52892682a81a01b13998092fcb8e6 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/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 From d8065c3b3eb3bae108a2fbc684221b1a1920d45d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:14:16 +0800 Subject: [PATCH 10/57] Move NoAccessToAccessibilityAPIError to XPCService.swift --- Core/Sources/Service/XPCService.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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() {} +} + From ea982606b2b93cb0d8f83f3c63bea8ba5b059d57 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:14:48 +0800 Subject: [PATCH 11/57] Move now to Workspace --- Tool/Sources/Workspace/Workspace.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 From d36a861a59ae66fd2f9ea1f3dd085a4637302c69 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:15:21 +0800 Subject: [PATCH 12/57] Make the extensions public --- Tool/Sources/XcodeInspector/Helpers.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From 7393c8919317361e97ebfd57bcb36908500a3c43 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:15:39 +0800 Subject: [PATCH 13/57] Fix incorrect variable call --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 2989d7ea..7cf34465 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -76,7 +76,7 @@ public final class XcodeInspector: ObservableObject { } public var realtimeActiveProjectURL: URL? { - latestActiveXcode?.realtimeProjectURL ?? activeWorkspaceURL + latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL } init() { From fbf56440705c292ed7e9f547908481b1609cfcb9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:16:21 +0800 Subject: [PATCH 14/57] Use XcodeInspector to get urls instead of Environment --- .../ChatPlugin/TerminalChatPlugin.swift | 13 ++-- .../RealtimeSuggestionController.swift | 11 ++-- .../PseudoCommandHandler.swift | 7 +- .../WindowBaseCommandHandler.swift | 20 +++--- Tool/Sources/Workspace/WorkspacePool.swift | 64 ++++++++++--------- 5 files changed, 58 insertions(+), 57 deletions(-) diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index 9022c788..a51b52a3 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,10 @@ 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) - }() + + guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL, + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL + else { return } await chatGPTService.memory.mutateHistory { history in history.append( diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 69e879b2..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 @@ -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) @@ -171,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) { @@ -210,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/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d9808fa1..5236337e 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 @@ -324,14 +323,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/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 From 837c2da60eb2ab9e1febb476fbe029eb93c7d0c6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 9 Jan 2024 22:30:07 +0800 Subject: [PATCH 15/57] Change currentDirectoryPath to currentDirectoryURL --- .../ChatPlugin/TerminalChatPlugin.swift | 19 +++++++++++-------- .../ShortcutChatPlugin.swift | 2 +- .../ShortcutInputChatPlugin.swift | 2 +- .../GitHubCopilotInstallationManager.swift | 2 +- .../GitIgnoreCheck/GitIgnoreCheck.swift | 4 ++-- Tool/Sources/Terminal/Terminal.swift | 12 ++++++------ 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift index a51b52a3..6e2d9d1e 100644 --- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift +++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift @@ -34,10 +34,16 @@ public actor TerminalChatPlugin: ChatPlugin { } do { + let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL + let projectURL = XcodeInspector.shared.realtimeActiveProjectURL - guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL, - let projectURL = XcodeInspector.shared.realtimeActiveProjectURL - else { return } + 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( @@ -56,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/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift index 81489513..c6a9bddf 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -76,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 85c3c15a..5616f072 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift @@ -76,7 +76,7 @@ public actor ShortcutInputChatPlugin: ChatPlugin { _ = try await terminal.runCommand( shell, arguments: ["-i", "-l", "-c", command], - currentDirectoryPath: "/", + currentDirectoryURL: nil, environment: [:] ) 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/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 }) From 012b4f9b47763a11edc031345801094a804e1f6a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 11 Jan 2024 17:28:45 +0800 Subject: [PATCH 16/57] Make activateXcode optional --- Pro | 2 +- .../XcodeInspector/XcodeInspector.swift | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Pro b/Pro index b49fbf24..1d6bb193 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit b49fbf244ee52892682a81a01b13998092fcb8e6 +Subproject commit 1d6bb1931fa34c5b61ee6aacada0548f0a76523b diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 7cf34465..bed510b0 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -549,15 +549,16 @@ extension XcodeAppInstanceInspector { // MARK: - Triggering Command public extension XcodeAppInstanceInspector { - func triggerCopilotCommand(name: String) async throws { + 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]) + try await triggerMenuItem(path: ["Editor", bundleName, name], activateXcode: activateXcode) } } public extension AppInstanceInspector { - func triggerMenuItem(path: [String]) async throws { + @MainActor + func triggerMenuItem(path: [String], activateXcode: Bool) async throws { guard !path.isEmpty else { return } struct CantRunCommand: Error, LocalizedError { @@ -567,7 +568,17 @@ public extension AppInstanceInspector { } } - if !runningApplication.isActive { runningApplication.activate() } + 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) @@ -617,7 +628,6 @@ public extension AppInstanceInspector { set theprocs to every process whose unix id is \ \(runningApplication.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 From e73363fe67ec542fe1e7b142dc57fbe937ef1ddc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 14 Jan 2024 16:34:36 +0800 Subject: [PATCH 17/57] Update TextSplitter to generate TextChunk --- .../RecursiveCharacterTextSplitter.swift | 24 +++--- .../DocumentTransformer/TextSplitter.swift | 84 ++++++++++++------- .../TextSplitterSeparatorSet.swift | 4 + 3 files changed, 74 insertions(+), 38 deletions(-) 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..3e01d1c6 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,48 @@ public extension TextSplitter { } } +public struct TextChunk { + public var text: String + public var startUTF16Offset: Int + public var endUTF16Offset: Int +} + 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 +107,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.. Date: Sun, 14 Jan 2024 16:34:49 +0800 Subject: [PATCH 18/57] Support creating CodeLanguage with file path --- .../SuggestionModel/LanguageIdentifierFromFilePath.swift | 8 ++++++++ 1 file changed, 8 insertions(+) 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) From 31a12e934ea718831d560531cd6aec0a3539945b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 15 Jan 2024 17:54:55 +0800 Subject: [PATCH 19/57] Update get code function --- .../ActiveDocumentChatContextCollector.swift | 5 ++++- .../Functions/GetCodeCodeAroundLineFunction.swift | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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 { From 7985d0a3851c05e8e35c90b89aaf55876238bfcf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 00:58:57 +0800 Subject: [PATCH 20/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 1d6bb193..64eec9e6 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 1d6bb1931fa34c5b61ee6aacada0548f0a76523b +Subproject commit 64eec9e6323837278e1541d7b9e6c694d4809197 From ee31bb11a3f37888b4a752db25dd0283707eb7a6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 15:53:10 +0800 Subject: [PATCH 21/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 64eec9e6..164adea1 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 64eec9e6323837278e1541d7b9e6c694d4809197 +Subproject commit 164adea14001491e478ed8dcdb704fba65737501 From c2d0fa2a91cc135ababa90636d6ad6bd481d8e49 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 16:14:21 +0800 Subject: [PATCH 22/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 164adea1..5eae05a6 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 164adea14001491e478ed8dcdb704fba65737501 +Subproject commit 5eae05a676a0874add6c7d2aefbfc8a544133780 From 7eebdb87294278d4811e19d8d6a2e556f3eba92f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 16:54:39 +0800 Subject: [PATCH 23/57] Supports esc to dismiss suggestion --- Core/Sources/Service/Service.swift | 13 +++++++------ .../PseudoCommandHandler.swift | 9 +++++++++ Pro | 2 +- Tool/Sources/Preferences/Keys.swift | 4 ++++ 4 files changed, 21 insertions(+), 7 deletions(-) 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 5236337e..f0a1fd47 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -241,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 { diff --git a/Pro b/Pro index 5eae05a6..353d7a40 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 5eae05a676a0874add6c7d2aefbfc8a544133780 +Subproject commit 353d7a40dab4f4856029545aaf44d389c30d1c9f 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") } From 27c576e187c5bbf7454c101fa769538fa6ca8aca Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 16:55:04 +0800 Subject: [PATCH 24/57] Change the close button in compact suggestion panel to dismiss instead of reject --- Core/Sources/Service/GUI/WidgetDataSource.swift | 7 +++++++ .../Providers/CodeSuggestionProvider.swift | 6 +++++- .../SuggestionPanelContent/CodeBlockSuggestionPanel.swift | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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/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/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) From b47de8f934e5441e2229bf6276bc2c2f44546888 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 16:57:04 +0800 Subject: [PATCH 25/57] Add a toggle for dismiss suggestion with ESC --- .../HostApp/FeatureSettings/SuggestionSettingsView.swift | 6 ++++++ 1 file changed, 6 insertions(+) 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 { From 57f25777e8c5cca514f18779dad7614acc3ed44c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 16:57:21 +0800 Subject: [PATCH 26/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 353d7a40..0b590967 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 353d7a40dab4f4856029545aaf44d389c30d1c9f +Subproject commit 0b590967a9b8993ff93599718f95278f4b834c4e From 25415537e79304410957daaf57434d94769bd1bb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 18:33:07 +0800 Subject: [PATCH 27/57] Add normal title bar to chat window --- .../SuggestionWidget/ChatWindowView.swift | 13 ++-- .../FeatureReducers/WidgetFeature.swift | 12 ++-- .../SuggestionWidget/ModuleDependency.swift | 2 +- .../SuggestionWidgetController.swift | 70 +++++++++---------- 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 12e11915..d49f6408 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,10 +29,6 @@ struct ChatWindowView: View { } ) { viewStore in VStack(spacing: 0) { - ChatTitleBar(store: store) - - Divider() - ChatTabBar(store: store) .frame(height: 26) @@ -41,9 +38,9 @@ struct ChatWindowView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .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) } } @@ -453,7 +450,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/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 164f4a07..86943827 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -581,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 = { @@ -602,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 @@ -613,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/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index f81e101b..d0b34ba4 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -122,26 +122,40 @@ public final class SuggestionWidgetController: NSObject { private lazy var chatPanelWindow = { let it = ChatWindow( contentRect: .zero, - styleMask: [.resizable], + styleMask: [.resizable, .titled, .miniaturizable], backing: .buffered, defer: false ) + it.minimizeWindow = { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } 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, + if #available(macOS 13, *) { [.primary] } + ] 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 }() @@ -249,31 +263,6 @@ extension SuggestionWidgetController: NSWindowDelegate { store.send(.chatPanel(.detachChatPanel)) } } - - public func windowDidBecomeKey(_ 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) - } - } - } } // MARK: - Window Subclasses @@ -287,17 +276,22 @@ class CanBecomeKeyWindow: NSWindow { 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 func miniaturize(_ sender: Any?) { + minimizeWindow() + } } From 1c81ab11f86f80c81e543755fa83b951106e51e7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 19:00:47 +0800 Subject: [PATCH 28/57] Add attach button to title bar --- .../SuggestionWidget/ChatWindowView.swift | 45 ++++++------------- .../SuggestionWidgetController.swift | 23 +++++++--- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index d49f6408..530947e3 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -52,20 +52,6 @@ struct ChatTitleBar: View { var body: some View { HStack(spacing: 6) { - TrafficLightButton( - isHovering: isHovering, - isActive: true, - color: Color(nsColor: .systemOrange), - action: { - store.send(.hideButtonClicked) - } - ) { - Image(systemName: "minus") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 8).weight(.heavy)) - } - .keyboardShortcut("m", modifiers: [.command]) - WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in TrafficLightButton( isHovering: isHovering, @@ -82,6 +68,8 @@ struct ChatTitleBar: View { } } + Spacer() + Button(action: { store.send(.closeActiveTabClicked) }) { @@ -90,25 +78,20 @@ struct ChatTitleBar: View { .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) - } + Button( + action: { + store.send(.hideButtonClicked) } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 8).weight(.heavy)) + } + .opacity(0) + .keyboardShortcut("m", modifiers: [.command]) } - .padding(.horizontal, 6) - .padding(.top, 1) - .frame(maxWidth: .infinity) - .frame(height: Style.chatWindowTitleBarHeight) + .buttonStyle(.plain) + .padding(.leading, 2) .onHover(perform: { hovering in isHovering = hovering }) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index d0b34ba4..7b1d3d34 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -129,6 +129,18 @@ public final class SuggestionWidgetController: NSObject { 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 = .left + return controller + }()) it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear @@ -138,7 +150,7 @@ public final class SuggestionWidgetController: NSObject { .transient, .fullScreenPrimary, .fullScreenAllowsTiling, - if #available(macOS 13, *) { [.primary] } + if #available(macOS 13, *) { [.primary] }, ] it.hasShadow = true it.contentView = NSHostingView( @@ -276,21 +288,22 @@ class CanBecomeKeyWindow: NSWindow { class ChatWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } - + var minimizeWindow: () -> Void = {} - + var isWindowHidden: Bool = false { didSet { alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 } } + var isPanelDisplayed: Bool = false { didSet { alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 } } - - override func miniaturize(_ sender: Any?) { + + override func miniaturize(_: Any?) { minimizeWindow() } } From 56151df1e20dac066b1c2aeea9aa1bad904cb962 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 16 Jan 2024 23:58:18 +0800 Subject: [PATCH 29/57] Fix mouse events in hidden chat window --- .../SuggestionWidget/SuggestionWidgetController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 7b1d3d34..40cb822b 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -302,6 +302,12 @@ class ChatWindow: NSWindow { alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 } } + + override var alphaValue: CGFloat { + didSet { + ignoresMouseEvents = alphaValue <= 0 + } + } override func miniaturize(_: Any?) { minimizeWindow() From 7cab434adaf5486ea401dfb704507a2212dc9f2c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 15:37:30 +0800 Subject: [PATCH 30/57] Adjust style of chat window --- Core/Sources/SuggestionWidget/ChatWindowView.swift | 8 +++++++- Core/Sources/SuggestionWidget/Styles.swift | 8 ++++---- .../SuggestionWidget/SuggestionWidgetController.swift | 4 ++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 530947e3..0e0131a1 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -29,6 +29,10 @@ struct ChatWindowView: View { } ) { viewStore in VStack(spacing: 0) { + Rectangle().fill(.regularMaterial).frame(height: 28) + + Divider() + ChatTabBar(store: store) .frame(height: 26) @@ -37,6 +41,8 @@ struct ChatWindowView: View { ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) .background(.regularMaterial) .onChange(of: viewStore.state.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) @@ -69,7 +75,7 @@ struct ChatTitleBar: View { } Spacer() - + Button(action: { store.send(.closeActiveTabClicked) }) { 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/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 40cb822b..0c85b0d3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -122,7 +122,7 @@ public final class SuggestionWidgetController: NSObject { private lazy var chatPanelWindow = { let it = ChatWindow( contentRect: .zero, - styleMask: [.resizable, .titled, .miniaturizable], + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false ) @@ -141,6 +141,7 @@ public final class SuggestionWidgetController: NSObject { controller.layoutAttribute = .left return controller }()) + it.titlebarAppearsTransparent = true it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear @@ -150,7 +151,6 @@ public final class SuggestionWidgetController: NSObject { .transient, .fullScreenPrimary, .fullScreenAllowsTiling, - if #available(macOS 13, *) { [.primary] }, ] it.hasShadow = true it.contentView = NSHostingView( From c36470330a92017fd7d84de8b10b12fbed8dc2b8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 15:57:02 +0800 Subject: [PATCH 31/57] Move detach/attach button to the right side --- .../SuggestionWidget/ChatWindowView.swift | 40 +++++++++---------- .../SuggestionWidgetController.swift | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 0e0131a1..cda1a21d 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -30,7 +30,7 @@ struct ChatWindowView: View { ) { viewStore in VStack(spacing: 0) { Rectangle().fill(.regularMaterial).frame(height: 28) - + Divider() ChatTabBar(store: store) @@ -58,24 +58,6 @@ struct ChatTitleBar: View { var body: some View { HStack(spacing: 6) { - WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in - TrafficLightButton( - isHovering: isHovering, - isActive: viewStore.state, - color: Color(nsColor: .systemCyan), - action: { - store.send(.toggleChatPanelDetachedButtonClicked) - } - ) { - Image(systemName: "pin.fill") - .foregroundStyle(.black.opacity(0.5)) - .font(Font.system(size: 6).weight(.black)) - .transformEffect(.init(translationX: 0, y: 0.5)) - } - } - - Spacer() - Button(action: { store.send(.closeActiveTabClicked) }) { @@ -95,9 +77,27 @@ struct ChatTitleBar: View { } .opacity(0) .keyboardShortcut("m", modifiers: [.command]) + + Spacer() + + WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in + TrafficLightButton( + isHovering: isHovering, + isActive: viewStore.state, + color: Color(nsColor: .systemCyan), + action: { + store.send(.toggleChatPanelDetachedButtonClicked) + } + ) { + Image(systemName: "pin.fill") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 6).weight(.black)) + .transformEffect(.init(translationX: 0, y: 0.5)) + } + } } .buttonStyle(.plain) - .padding(.leading, 2) + .padding(.trailing, 8) .onHover(perform: { hovering in isHovering = hovering }) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 0c85b0d3..fc28fa87 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -138,7 +138,7 @@ public final class SuggestionWidgetController: NSObject { ))) controller.view = view view.frame = .init(x: 0, y: 0, width: 100, height: 40) - controller.layoutAttribute = .left + controller.layoutAttribute = .right return controller }()) it.titlebarAppearsTransparent = true From 0920414e43f9850cb1bd079821d3d6a2bf7c265b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 15:57:25 +0800 Subject: [PATCH 32/57] Fix the conflicts between fullscreen and attach --- .../FeatureReducers/ChatPanelFeature.swift | 49 +++++++++++++++++-- .../SuggestionWidgetController.swift | 17 +++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 84446c9d..823f0b48 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,17 +118,42 @@ public struct ChatPanelFeature: ReducerProtocol { return .none case .toggleChatPanelDetachedButtonClicked: - state.chatPanelInASeparateWindow.toggle() - return .none + if state.chatPanelInASeparateWindow { + return .run { send in + await send(.attachChatPanel) + } + } else { + return .run { send in + await send(.detachChatPanel) + } + } case .detachChatPanel: state.chatPanelInASeparateWindow = true 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 +268,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 +280,7 @@ public struct ChatPanelFeature: ReducerProtocol { return .run { send in await send(.closeTabButtonClicked(id: id)) } - + case .chatTab: return .none } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index fc28fa87..102c2e64 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -275,6 +275,22 @@ extension SuggestionWidgetController: NSWindowDelegate { store.send(.chatPanel(.detachChatPanel)) } } + + public func windowWillEnterFullScreen(_ notification: Notification) { + guard (notification.object as? NSWindow) === chatPanelWindow else { return } + 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)) + } + } } // MARK: - Window Subclasses @@ -289,6 +305,7 @@ class ChatWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } + var minimizeWindow: () -> Void = {} var isWindowHidden: Bool = false { From 1100db904d7637972e2ffd4e6cebed98daf73f4c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 16:34:09 +0800 Subject: [PATCH 33/57] Fix reattaching animation --- .../FeatureReducers/ChatPanelFeature.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 823f0b48..b1742cfa 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -118,15 +118,14 @@ public struct ChatPanelFeature: ReducerProtocol { return .none case .toggleChatPanelDetachedButtonClicked: - if state.chatPanelInASeparateWindow { + if state.isFullScreen, state.chatPanelInASeparateWindow { return .run { send in await send(.attachChatPanel) } - } else { - return .run { send in - await send(.detachChatPanel) - } } + + state.chatPanelInASeparateWindow.toggle() + return .none case .detachChatPanel: state.chatPanelInASeparateWindow = true From c85654ae287b0bb50a24179ef13d09ff95bc0323 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 21:35:08 +0800 Subject: [PATCH 34/57] Support exporting custom commands --- .../CustomCommandSettings/CustomCommand.swift | 65 +++++++++++++++++++ .../CustomCommandView.swift | 35 +++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 880704c4..017b3ac1 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,68 @@ 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): + 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..fd2e88da 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,34 @@ 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 { + print(url) + store.send(.importCommand(at: url)) + } else if let error { + toast(error.localizedDescription, .error) + } + } + } + } + + return !jsonFiles.isEmpty } } @@ -143,6 +172,10 @@ struct CustomCommandView: View { Button("Remove") { store.send(.deleteCommand(command)) } + + Button("Export") { + store.send(.exportCommand(command)) + } } } } From 5d6b7e3e91f1b642d26baabbbeede2360162af9c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 21:40:49 +0800 Subject: [PATCH 35/57] Update --- .../HostApp/CustomCommandSettings/CustomCommandView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift index fd2e88da..22594715 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift @@ -111,7 +111,6 @@ struct CustomCommandView: View { file.loadInPlaceFileRepresentation(forTypeIdentifier: "public.json") { url, _, error in Task { @MainActor in if let url { - print(url) store.send(.importCommand(at: url)) } else if let error { toast(error.localizedDescription, .error) From 1a2684642328adb3b66cb8fcdd52f9e201042eb9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 22:25:55 +0800 Subject: [PATCH 36/57] Add accessibility API status to menu item --- ExtensionService/AppDelegate+Menu.swift | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 7951c756..37842c33 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -12,6 +12,10 @@ extension AppDelegate { .init("xcodeInspectorDebugMenu") } + fileprivate var accessibilityAPIPermissionMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("accessibilitAPIPermissionMenuItem") + } + @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem( @@ -61,6 +65,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 +86,7 @@ extension AppDelegate { statusBarMenu.addItem(openGlobalChat) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(xcodeInspectorDebug) + statusBarMenu.addItem(accessibilityAPIPermission) statusBarMenu.addItem(quitItem) statusBarMenu.delegate = self @@ -92,6 +104,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() @@ -117,7 +138,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)", @@ -157,3 +178,4 @@ private extension NSMenuItem { return item } } + From 4ad3387d5b447d076ea4d21337fe87650deb3027 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 17 Jan 2024 22:26:42 +0800 Subject: [PATCH 37/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 0b590967..72649fb1 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 0b590967a9b8993ff93599718f95278f4b834c4e +Subproject commit 72649fb190839ebd5fd1649bbfff8f32440a5b64 From 8433e9b7b6a14b67d5cf1c54618d657709c4936a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 20 Jan 2024 17:36:32 +0800 Subject: [PATCH 38/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 72649fb1..85cc32a6 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 72649fb190839ebd5fd1649bbfff8f32440a5b64 +Subproject commit 85cc32a6d585ccc96859bd55c9edd111678db5b7 From d1897989839892fab51bcf76c82886bcb0ab99e5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 14:49:41 +0800 Subject: [PATCH 39/57] Add merged() to TextChunk --- .../DocumentTransformer/TextSplitter.swift | 15 +++++- .../TextSplitterTests/TextChunkTests.swift | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 Tool/Tests/LangChainTests/TextSplitterTests/TextChunkTests.swift diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 3e01d1c6..4e467106 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -50,10 +50,23 @@ public extension TextSplitter { } } -public struct TextChunk { +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 { 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) + } +} From 1f1fac10b06e49975e146a70a8c7bcb630f13080 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 14:50:11 +0800 Subject: [PATCH 40/57] Update tests --- .../RecursiveCharacterTextSplitterTests.swift | 62 ++++++++++++++++--- .../TextSplitterTests/TextSplitterTests.swift | 36 +++++++++-- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift index fa1d6711..9c0019f2 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: 97, + endUTF16Offset: 110 + ), ]) } @@ -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: 97, + endUTF16Offset: 110 + ), + .init( + text: "\n@MainActor", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "\nprivate class Dog: Animal {\n var name: String\n var legs: Int\n init(name: String, legs:", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "func makeSound() {\n print(\"Woof!\")\n }\n}\n", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "\nfinal class Cat: Animal {\n var name: String\n var legs: Int\n init(name: String, legs: Int)", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + .init( + text: "func makeSound() {\n print(\"Meow!\")\n }\n}", + startUTF16Offset: 97, + endUTF16Offset: 110 + ), + ] ) } } 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 }) } } From 854b6114d26e739818b989ddc463147dcf6419fa Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 14:50:33 +0800 Subject: [PATCH 41/57] Adjust prompt for retrieved content --- .../OpenAIService/Memory/AutoManagedChatGPTMemory.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 56577520..d4a14e0a 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -290,13 +290,11 @@ extension AutoManagedChatGPTMemory { 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) From 861e462ad1aed48ebd7a3bfc4f72fc34561ec17f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 14:50:42 +0800 Subject: [PATCH 42/57] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 85cc32a6..59d36209 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 85cc32a6d585ccc96859bd55c9edd111678db5b7 +Subproject commit 59d3620976c45a2dafdf50332317cca266a32488 From 69442b5b69e0e9517b735f93572ddf423b66f8a2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 15:55:47 +0800 Subject: [PATCH 43/57] Move prompt reformat to ChatGPTService So that we don't have to implement it for each memory --- .../APIs/GoogleAICompletionAPI.swift | 86 +++++++++++++++++-- .../APIs/GoogleAICompletionStreamAPI.swift | 7 +- .../APIs/OpenAICompletionAPI.swift | 2 +- .../APIs/OpenAICompletionStreamAPI.swift | 9 +- .../OpenAIService/ChatGPTService.swift | 24 ++++-- .../Memory/AutoManagedChatGPTMemory.swift | 7 +- ...ManagedChatGPTMemoryGoogleAIStrategy.swift | 75 ---------------- ...toManagedChatGPTMemoryOpenAIStrategy.swift | 4 - .../ChatGPTStreamTests.swift | 8 +- 9 files changed, 117 insertions(+), 105 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift index 33d443af..a77d5f5a 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift @@ -3,10 +3,86 @@ import Foundation import GoogleGenerativeAI import Preferences +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( + 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: references, + remainingTokenCount: remainingTokenCount + ) + } +} + struct GoogleCompletionAPI: CompletionAPI { let apiKey: String let model: ChatModel var requestBody: CompletionRequestBody + let prompt: ChatGPTPrompt func callAsFunction() async throws -> CompletionResponseBody { let aiModel = GenerativeModel( @@ -17,14 +93,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 +108,7 @@ struct GoogleCompletionAPI: CompletionAPI { do { let response = try await aiModel.generateContent(history) - + return .init( object: "chat.completion", model: model.info.modelName, @@ -64,7 +140,7 @@ struct GoogleCompletionAPI: CompletionAPI { return "Internal Error: \(s)" } } - + switch error { case let .internalError(underlying): throw ErrorWrapper(error: underlying) 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 d4a14e0a..5bcef7d3 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 -> ( diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift index 51a2e42b..17d6240b 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift @@ -26,81 +26,6 @@ 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 - ) - } } } 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/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift index 4157f325..5349f85e 100644 --- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift +++ b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift @@ -15,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() } @@ -76,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() @@ -160,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() @@ -266,7 +266,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() From 20f5de9d0dccc5e4005d5103acd2ccfc993fbfbc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 15:55:56 +0800 Subject: [PATCH 44/57] Update tests --- .../RecursiveCharacterTextSplitterTests.swift | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift b/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift index 9c0019f2..20b2e768 100644 --- a/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift +++ b/Tool/Tests/LangChainTests/TextSplitterTests/RecursiveCharacterTextSplitterTests.swift @@ -23,9 +23,9 @@ final class RecursiveCharacterTextSplitterTests: XCTestCase { endUTF16Offset: 97 ), .init( - text: "of Congress and the Cabinet. Justices of the Supreme Court. My fellow Americans.", - startUTF16Offset: 97, - endUTF16Offset: 110 + text: " of Congress and the Cabinet. Justices of the Supreme Court. My fellow Americans.", + startUTF16Offset: 81, + endUTF16Offset: 162 ), ]) } @@ -76,43 +76,43 @@ final class RecursiveCharacterTextSplitterTests: XCTestCase { [ .init( text: "protocol Animal {\n var name: String { get }\n var legs: Int { get }\n func makeSound()\n}\n", - startUTF16Offset: 97, - endUTF16Offset: 110 + startUTF16Offset: 0, + endUTF16Offset: 96 ), .init( text: "\n@MainActor", - startUTF16Offset: 97, - endUTF16Offset: 110 + startUTF16Offset: 96, + endUTF16Offset: 107 ), .init( text: "\nprivate class Dog: Animal {\n var name: String\n var legs: Int\n init(name: String, legs:", - startUTF16Offset: 97, - endUTF16Offset: 110 + startUTF16Offset: 107, + endUTF16Offset: 203 ), .init( - text: "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", - startUTF16Offset: 97, - endUTF16Offset: 110 + 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: 97, - endUTF16Offset: 110 + 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: 97, - endUTF16Offset: 110 + startUTF16Offset: 321, + endUTF16Offset: 420 ), .init( - text: "String, legs: Int) {\n self.name = name\n self.legs = legs\n }\n func makeSound()", - startUTF16Offset: 97, - endUTF16Offset: 110 + 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: 97, - endUTF16Offset: 110 + text: " func makeSound() {\n print(\"Meow!\")\n }\n}", + startUTF16Offset: 479, + endUTF16Offset: 532 ), ] ) From 70ceefcbe61aa03e4ea9c2ef34de708873452788 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 16:35:13 +0800 Subject: [PATCH 45/57] Add tests for prompt reformat for Google Gemini --- .../APIs/GoogleAICompletionAPI.swift | 181 +++++++++++------- ...ManagedChatGPTMemoryGoogleAIStrategy.swift | 34 ---- .../OpenAIService/Memory/ChatGPTMemory.swift | 2 +- ...matPromptToBeGoogleAICompatibleTests.swift | 158 +++++++++++++++ 4 files changed, 270 insertions(+), 105 deletions(-) create mode 100644 Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift index a77d5f5a..ded6e372 100644 --- a/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift +++ b/Tool/Sources/OpenAIService/APIs/GoogleAICompletionAPI.swift @@ -3,6 +3,83 @@ import Foundation import GoogleGenerativeAI import Preferences +struct GoogleCompletionAPI: CompletionAPI { + let apiKey: String + let model: ChatModel + var requestBody: CompletionRequestBody + let prompt: ChatGPTPrompt + + func callAsFunction() async throws -> CompletionResponseBody { + let aiModel = GenerativeModel( + name: model.info.modelName, + apiKey: apiKey, + generationConfig: .init(GenerationConfig( + temperature: requestBody.temperature.map(Float.init), + topP: requestBody.top_p.map(Float.init) + )) + ) + let history = prompt.googleAICompatible.history.map { message in + ModelContent( + ChatMessage( + role: message.role, + content: message.content, + name: message.name, + functionCall: message.functionCall.map { + .init(name: $0.name, arguments: $0.arguments) + } + ) + ) + } + + do { + let response = try await aiModel.generateContent(history) + + return .init( + object: "chat.completion", + model: model.info.modelName, + usage: .init(prompt_tokens: 0, completion_tokens: 0, total_tokens: 0), + choices: response.candidates.enumerated().map { + let (index, candidate) = $0 + return .init( + message: .init( + role: .assistant, + content: candidate.content.parts.first(where: { part in + if let text = part.text { + return !text.isEmpty + } else { + return false + } + })?.text ?? "" + ), + index: index, + finish_reason: candidate.finishReason?.rawValue ?? "" + ) + } + ) + } catch let error as GenerateContentError { + struct ErrorWrapper: Error, LocalizedError { + let error: Error + var errorDescription: String? { + var s = "" + dump(error, to: &s) + return "Internal Error: \(s)" + } + } + + switch error { + case let .internalError(underlying): + throw ErrorWrapper(error: underlying) + case .promptBlocked: + throw error + case .responseStoppedEarly: + throw error + } + } catch { + throw error + } + } +} + extension ChatGPTPrompt { var googleAICompatible: ChatGPTPrompt { var history = self.history @@ -20,6 +97,7 @@ extension ChatGPTPrompt { guard lastIndex >= 0 else { // first message if message.role == .system { reformattedHistory.append(.init( + id: message.id, role: .user, content: ModelContent.convertContent(of: message) )) @@ -40,6 +118,7 @@ extension ChatGPTPrompt { .convertRole(message.role) { let newMessage = ChatMessage( + id: message.id, role: message.role == .assistant ? .assistant : .user, content: """ \(ModelContent.convertContent(of: lastMessage)) @@ -78,80 +157,42 @@ extension ChatGPTPrompt { } } -struct GoogleCompletionAPI: CompletionAPI { - let apiKey: String - let model: ChatModel - var requestBody: CompletionRequestBody - let prompt: ChatGPTPrompt - - func callAsFunction() async throws -> CompletionResponseBody { - let aiModel = GenerativeModel( - name: model.info.modelName, - apiKey: apiKey, - generationConfig: .init(GenerationConfig( - temperature: requestBody.temperature.map(Float.init), - topP: requestBody.top_p.map(Float.init) - )) - ) - let history = prompt.googleAICompatible.history.map { message in - ModelContent( - ChatMessage( - role: message.role, - content: message.content, - name: message.name, - functionCall: message.functionCall.map { - .init(name: $0.name, arguments: $0.arguments) - } - ) - ) +extension ModelContent { + static func convertRole(_ role: ChatMessage.Role) -> String { + switch role { + case .user, .system, .function: + return "user" + case .assistant: + return "model" } + } - do { - let response = try await aiModel.generateContent(history) - - return .init( - object: "chat.completion", - model: model.info.modelName, - usage: .init(prompt_tokens: 0, completion_tokens: 0, total_tokens: 0), - choices: response.candidates.enumerated().map { - let (index, candidate) = $0 - return .init( - message: .init( - role: .assistant, - content: candidate.content.parts.first(where: { part in - if let text = part.text { - return !text.isEmpty - } else { - return false - } - })?.text ?? "" - ), - index: index, - finish_reason: candidate.finishReason?.rawValue ?? "" - ) - } - ) - } catch let error as GenerateContentError { - struct ErrorWrapper: Error, LocalizedError { - let error: Error - var errorDescription: String? { - var s = "" - dump(error, to: &s) - return "Internal Error: \(s)" - } - } - - switch error { - case let .internalError(underlying): - throw ErrorWrapper(error: underlying) - case .promptBlocked: - throw error - case .responseStoppedEarly: - throw error + 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 ?? " " } - } catch { - throw error } } + + 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/AutoManagedChatGPTMemoryGoogleAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift index 17d6240b..1451bf67 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryGoogleAIStrategy.swift @@ -29,38 +29,4 @@ extension AutoManagedChatGPTMemory { } } -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/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/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)) + } +} + From cf7535cb7cbba136368c4801e7e764e0ee83e2e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 17:19:50 +0800 Subject: [PATCH 46/57] Split XcodeInspector.swift into multiple files --- .../XcodeInspector/AppInstanceInspector.swift | 16 + .../Apps/XcodeAppInstanceInspector.swift | 305 +++++++++++ .../XcodeInspector+TriggerCommand.swift | 142 ++++++ .../XcodeInspector/XcodeInspector.swift | 473 +----------------- .../XcodeInspector/XcodeWindowInspector.swift | 2 - 5 files changed, 481 insertions(+), 457 deletions(-) create mode 100644 Tool/Sources/XcodeInspector/AppInstanceInspector.swift create mode 100644 Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift create mode 100644 Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift new file mode 100644 index 00000000..8884ebb1 --- /dev/null +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -0,0 +1,16 @@ +import AppKit +import Foundation + +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) + } +} + diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift new file mode 100644 index 00000000..e7ffd363 --- /dev/null +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -0,0 +1,305 @@ +import AppKit +import AXExtension +import AXNotificationStream +import Combine +import Foundation + +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/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 bed510b0..c6cc11dd 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -79,6 +79,24 @@ public final class XcodeInspector: ObservableObject { latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL } + public func restart() { + 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 + xcodes = [] + } + init() { let runningApplications = NSWorkspace.shared.runningApplications xcodes = runningApplications @@ -230,458 +248,3 @@ public final class XcodeInspector: ObservableObject { } } -// 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 - } -} - -// MARK: - Triggering Command - -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/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index d8858754..e37e1014 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -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() } From c909e080376c8beca84190d2fd3f2bd53e3e1eda Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 18:53:46 +0800 Subject: [PATCH 47/57] Update --- .../HostApp/CustomCommandSettings/CustomCommand.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift index 017b3ac1..212b8313 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommand.swift @@ -84,6 +84,13 @@ struct CustomCommandFeature: ReducerProtocol { } 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) From 4d6e69015ba65211df09a7c2636c6af67e8307ea Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 18:54:16 +0800 Subject: [PATCH 48/57] Add restart method to XcodeInspector --- .../XcodeInspector/XcodeInspector.swift | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index c6cc11dd..3ce87aec 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -79,25 +79,24 @@ public final class XcodeInspector: ObservableObject { latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL } - public func restart() { - 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 - xcodes = [] - } - - init() { + 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 } @@ -107,6 +106,10 @@ public final class XcodeInspector: ObservableObject { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) + } + + init() { + restart() Task { // Did activate app if let activeXcode { From 7b18d0fd234b0b2d71a92dc8405fdaa47f6ddc7c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 18:54:31 +0800 Subject: [PATCH 49/57] Make appElement a computed property --- Tool/Sources/XcodeInspector/AppInstanceInspector.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 8884ebb1..1613a8b7 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -2,7 +2,9 @@ import AppKit import Foundation public class AppInstanceInspector: ObservableObject { - public let appElement: AXUIElement + public var appElement: AXUIElement { + AXUIElementCreateApplication(runningApplication.processIdentifier) + } public let runningApplication: NSRunningApplication public var isActive: Bool { runningApplication.isActive } public var isXcode: Bool { runningApplication.isXcode } @@ -10,7 +12,6 @@ public class AppInstanceInspector: ObservableObject { init(runningApplication: NSRunningApplication) { self.runningApplication = runningApplication - appElement = AXUIElementCreateApplication(runningApplication.processIdentifier) } } From 0c56c85e9a3601ccff215a57160ec8e413e059a1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 22:50:38 +0800 Subject: [PATCH 50/57] Restart observations when XcodeInspector restarts --- .../XcodeInspector/XcodeInspector.swift | 160 ++++++++++-------- 1 file changed, 87 insertions(+), 73 deletions(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 3ce87aec..917b6f4f 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -13,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, @@ -79,6 +80,10 @@ public final class XcodeInspector: ObservableObject { latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL } + init() { + restart() + } + public func restart(cleanUp: Bool = false) { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } @@ -96,7 +101,7 @@ public final class XcodeInspector: ObservableObject { focusedElement = nil completionPanel = nil } - + let runningApplications = NSWorkspace.shared.runningApplications xcodes = runningApplications .filter { $0.isXcode } @@ -106,76 +111,85 @@ public final class XcodeInspector: ObservableObject { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - } - - init() { - restart() + + appChangeObservations.forEach { $0.cancel() } + appChangeObservations.removeAll() - 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 - } } - } - } + + 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 + } - 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) + if let activeXcode = self.xcodes.first(where: \.isActive) { + self.setActiveXcode(activeXcode) + } + } } } } } } + + appChangeObservations.insert(appChangeTask) } + @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication @@ -229,23 +243,23 @@ 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) } From 28ead259066eeb6d275196affae45341b29cc8ad Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 22:50:51 +0800 Subject: [PATCH 51/57] Update --- .../Apps/XcodeAppInstanceInspector.swift | 79 +++++++++++-------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index e7ffd363..7de0eb03 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -5,11 +5,11 @@ import Combine import Foundation 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]() + @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) @@ -72,10 +72,10 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { override init(runningApplication: NSRunningApplication) { super.init(runningApplication: runningApplication) - observeFocusedWindow() - observeAXNotifications() - - Task { + 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) { @@ -84,6 +84,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } + @MainActor func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { @@ -104,16 +105,19 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { 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) @@ -127,6 +131,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } + @MainActor func refresh() { if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { focusedWindow.refresh() @@ -135,16 +140,19 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } + @MainActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] - let focusedWindowChanged = Task { - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedWindowChangedNotification - ) - for await _ in notification { + 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() } @@ -153,19 +161,23 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(focusedWindowChanged) updateWorkspaceInfo() - let updateTabsTask = Task { @MainActor in - let notification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXApplicationDeactivatedNotification - ) + + let elementChangeNotification = AXNotificationStream( + app: runningApplication, + notificationNames: kAXFocusedUIElementChangedNotification, + kAXApplicationDeactivatedNotification + ) + + let updateTabsTask = Task { @MainActor [weak self] in if #available(macOS 13.0, *) { - for await _ in notification.debounce(for: .seconds(2)) { + for await _ in elementChangeNotification.debounce(for: .seconds(2)) { + guard let self else { return } try Task.checkCancellation() updateWorkspaceInfo() } } else { - for await _ in notification { + for await _ in elementChangeNotification { + guard let self else { return } try Task.checkCancellation() updateWorkspaceInfo() } @@ -174,19 +186,22 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(updateTabsTask) - let completionPanelTask = Task { - let stream = AXNotificationStream( - app: runningApplication, - notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification - ) + 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 } - 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 + event.element.identifier == "_XC_COMPLETION_TABLE_" + || event.element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil } switch event.name { case kAXCreatedNotification: From 1aa0803fd20c1f9f961cd775cc41d77f86f3675e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 22:51:10 +0800 Subject: [PATCH 52/57] Add Active Source Editor to menu bar app --- ExtensionService/AppDelegate+Menu.swift | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 37842c33..3f17f39d 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -16,6 +16,10 @@ extension AppDelegate { .init("accessibilitAPIPermissionMenuItem") } + fileprivate var sourceEditorDebugMenu: NSUserInterfaceItemIdentifier { + .init("sourceEditorDebugMenu") + } + @objc func buildStatusBarMenu() { let statusBar = NSStatusBar.system statusBarItem = statusBar.statusItem( @@ -122,6 +126,17 @@ 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 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)", @@ -161,12 +176,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( From 0a2d3713c66be20d5ad13c03dccf32f9da17cc9f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 21 Jan 2024 23:25:40 +0800 Subject: [PATCH 53/57] Bump version 0.30.0 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 910a84b8431a7bc632123a839a83ace401b87e5b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 22 Jan 2024 00:58:31 +0800 Subject: [PATCH 54/57] Fix retrieved content number in rag when context window is not big enough --- .../Memory/AutoManagedChatGPTMemory.swift | 8 +- ...edChatGPTMemoryRetrievedContentTests.swift | 178 ++++++++++++++++++ .../LimitMessagesTests.swift | 2 +- 3 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 5bcef7d3..7675c9ff 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -286,8 +286,6 @@ extension AutoManagedChatGPTMemory { text += """ Here are the information you know about the system and the project, \ separated by \(separator) - - """ } @@ -302,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: []) @@ -340,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 { @@ -355,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/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift new file mode 100644 index 00000000..488a4f48 --- /dev/null +++ b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift @@ -0,0 +1,178 @@ +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 + """) + } +} + +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/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift index 0393805b..1173494a 100644 --- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift +++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift @@ -4,7 +4,7 @@ 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", From 670619948a06d978fd0c948a92866e25bf027e6b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 22 Jan 2024 01:00:39 +0800 Subject: [PATCH 55/57] Add tests --- ...edChatGPTMemoryRetrievedContentTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift index 488a4f48..8e1fb855 100644 --- a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift +++ b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift @@ -159,6 +159,70 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase { 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 { From d339f97f7b6f665d22cda7bfffaa4ac0d527595d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 22 Jan 2024 12:45:21 +0800 Subject: [PATCH 56/57] Add more information to Xcode Inspector debug menu --- ExtensionService/AppDelegate+Menu.swift | 16 ++++++++++++++++ .../XcodeInspector/XcodeWindowInspector.swift | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 3f17f39d..199a5ab4 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -126,6 +126,22 @@ 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( diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index e37e1014..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 From 1ad6a58fdcb1b063d5f1bd09ef6065edf2dba610 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 22 Jan 2024 16:16:11 +0800 Subject: [PATCH 57/57] Update appcast.xml --- appcast.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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