diff --git a/AppIcon.png b/AppIcon.png index 160db273..1f70976c 100644 Binary files a/AppIcon.png and b/AppIcon.png differ diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index 9ece01b6..056e5761 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -786,7 +786,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -814,7 +814,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; PRODUCT_NAME = Copilot; @@ -967,7 +967,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_MODULE_NAME = Copilot_for_Xcode; @@ -1001,7 +1001,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; PRODUCT_NAME = "$(HOST_APP_NAME)"; @@ -1017,7 +1017,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1031,7 +1031,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5YKZ4Y3DAW; ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; @@ -1061,7 +1061,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1094,7 +1094,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = "$(APP_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; @@ -1114,7 +1114,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1133,7 +1133,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..457c1fbf 100644 --- a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "app-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "app-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "app-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "app-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "app-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "app-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "app-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png new file mode 100644 index 00000000..f7d77720 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png new file mode 100644 index 00000000..da0bb247 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@128w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png new file mode 100644 index 00000000..4f3fcc40 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@16w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png new file mode 100644 index 00000000..1f70976c Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@256w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png new file mode 100644 index 00000000..44400214 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@32w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png new file mode 100644 index 00000000..78d81e50 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@512w.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png new file mode 100644 index 00000000..a6aae457 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/app-icon@64w.png differ diff --git a/Core/Package.swift b/Core/Package.swift index 8024192e..6cd0910a 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -8,7 +8,7 @@ import PackageDescription let package = Package( name: "Core", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "Service", diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 2923d904..28443876 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -1,3 +1,4 @@ +import AppKit import ChatBasic import ChatService import ComposableArchitecture @@ -124,8 +125,6 @@ struct Chat { case sendMessage(UUID) } - @Dependency(\.openURL) var openURL - var body: some ReducerOf { BindingReducer() @@ -214,7 +213,7 @@ struct Chat { print(error) } } else if let url = URL(string: reference.uri), url.scheme != nil { - await openURL(url) + NSWorkspace.shared.open(url) } } diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index d48d1486..ec627113 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -158,7 +158,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v20.8+)") + Text("Path to Node (v22.0+)") } Text( diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 3dabb27a..25db646f 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -26,8 +26,8 @@ public final class SimpleModificationAgent: ModificationAgent { generateDescriptionRequirement: false ) - for try await (code, description) in stream { - continuation.yield(.code(code)) + for try await response in stream { + continuation.yield(response) } continuation.finish() @@ -51,7 +51,7 @@ public final class SimpleModificationAgent: ModificationAgent { isDetached: Bool, extraSystemPrompt: String?, generateDescriptionRequirement: Bool? - ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + ) async throws -> AsyncThrowingStream { let userPreferredLanguage = UserDefaults.shared.value(for: \.chatGPTLanguage) let textLanguage = { if !UserDefaults.shared @@ -226,32 +226,38 @@ public final class SimpleModificationAgent: ModificationAgent { history.append(.init(role: .user, content: requirement)) } } - let stream = chatGPTService.send(memory).compactMap { response in - switch response { - case let .partialText(token): return token - default: return nil - } - }.eraseToThrowingStream() - + let stream = chatGPTService.send(memory) + return .init { continuation in - Task { - var content = "" - var extracted = extractCodeAndDescription(from: content) + let task = Task { + let parser = ExplanationThenCodeStreamParser() do { - for try await fragment in stream { - content.append(fragment) - extracted = extractCodeAndDescription(from: content) - if !content.isEmpty, extracted.code.isEmpty { - continuation.yield((code: content, description: "")) - } else { - continuation.yield(extracted) + func yield(fragments: [ExplanationThenCodeStreamParser.Fragment]) { + for fragment in fragments { + switch fragment { + case let .code(code): + continuation.yield(.code(code)) + case let .explanation(explanation): + continuation.yield(.explanation(explanation)) + } } } + + for try await response in stream { + guard case let .partialText(fragment) = response else { continue } + try Task.checkCancellation() + await yield(fragments: parser.yield(fragment)) + } + await yield(fragments: parser.finish()) continuation.finish() } catch { continuation.finish(throwing: error) } } + + continuation.onTermination = { _ in + task.cancel() + } } } } diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 64d614a3..b011bd78 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -61,7 +61,6 @@ public final class ScheduledCleaner { }.result await workspace.cleanUp(availableTabs: []) await service.workspacePool.removeWorkspace(url: url) - await service.overlayWindowController.removeController(for: url) } else { let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) .union(workspaceInfos[.unknown]?.tabs ?? []) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index d7f83696..c1d38d78 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -486,8 +486,7 @@ struct PseudoCommandHandler: CommandHandler { #endif } else { Task { - @Dependency(\.openURL) var openURL - await openURL(url) + NSWorkspace.shared.open(url) } } case let .builtinExtension(extensionIdentifier, id, _): @@ -688,7 +687,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = await { if let sourceEditor { sourceEditor } - else { await XcodeInspector.shared.focusedEditor } + else { await XcodeInspector.shared.latestFocusedEditor } }() else { return nil } if Task.isCancelled { return nil } diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift index cf9a4690..022b424c 100644 --- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -51,10 +51,7 @@ final class ChatPanelWindow: WidgetWindow { }()) titlebarAppearsTransparent = true isReleasedWhenClosed = false - isOpaque = false - backgroundColor = .clear level = widgetLevel(1) - hasShadow = true contentView = NSHostingView( rootView: ChatWindowView( diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 48fac7f4..cb68435f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -173,33 +173,48 @@ public struct PromptToCodePanel { range: snippet.attachedRange, references: context.references, topics: context.topics - )).timedDebounce(for: 0.4) + )).map { + switch $0 { + case let .code(code): + return (code: code, description: "") + case let .explanation(explanation): + return (code: "", description: explanation) + } + }.timedDebounce(for: 0.4) { lhs, rhs in + ( + code: lhs.code + rhs.code, + description: lhs.description + rhs.description + ) + } do { for try await response in stream { try Task.checkCancellation() - - switch response { - case let .code(code): - await send(.snippetPanel(.element( - id: snippet.id, - action: .modifyCodeChunkReceived( - code: code, - description: "" - ) - ))) - } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeChunkReceived( + code: response.code, + description: response.description + ) + ))) } + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) } catch is CancellationError { + await send(.snippetPanel(.element( + id: snippet.id, + action: .modifyCodeFinished + ))) throw CancellationError() } catch { - try Task.checkCancellation() if (error as NSError).code == NSURLErrorCancelled { await send(.snippetPanel(.element( id: snippet.id, - action: .modifyCodeFailed(error: "Cancelled") + action: .modifyCodeFinished ))) - return + throw CancellationError() } await send(.snippetPanel(.element( id: snippet.id, @@ -316,8 +331,8 @@ public struct PromptToCodeSnippetPanel { return .none case let .modifyCodeChunkReceived(code, description): - state.snippet.modifiedCode = code - state.snippet.description = description + state.snippet.modifiedCode += code + state.snippet.description += description return .none case let .modifyCodeFailed(error): diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index d048f360..ef3b560c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -288,12 +288,8 @@ extension PromptToCodePanelView { let isResponding = store.promptToCodeState.isGenerating let isCodeEmpty = store.promptToCodeState.snippets .allSatisfy(\.modifiedCode.isEmpty) - let isDescriptionEmpty = store.promptToCodeState.snippets - .allSatisfy(\.description.isEmpty) var isRespondingButCodeIsReady: Bool { - isResponding - && !isCodeEmpty - && !isDescriptionEmpty + isResponding && !isCodeEmpty } if !isResponding || isRespondingButCodeIsReady { HStack { @@ -619,20 +615,23 @@ extension PromptToCodePanelView { var body: some View { WithPerceptionTracking { - VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { SnippetTitleBar( store: store, language: language, codeForegroundColor: codeForegroundColor, isAttached: isAttached ) + + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent( store: store, language: language, isGenerating: isGenerating, codeForegroundColor: codeForegroundColor ) - DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + ErrorMessage(store: store) } } @@ -1146,7 +1145,12 @@ extension PromptToCodePanelView { ChatMessage.Reference( title: "Foo", content: "struct Foo { var foo: Int }", - kind: .symbol(.struct, uri: "file:///path/to/file.txt", startLine: 13, endLine: 13) + kind: .symbol( + .struct, + uri: "file:///path/to/file.txt", + startLine: 13, + endLine: 13 + ) ), ], )), diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 7ae716f6..f07816bf 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -42,7 +42,7 @@ struct WidgetView: View { value: isHovering ) .animation( - .easeInOut(duration: 0.2), + .easeInOut(duration: 0.4), value: store.isProcessing ) } @@ -54,11 +54,9 @@ struct WidgetAnimatedCapsule: View { let store: StoreOf var isHovering: Bool - @State private var animatedProgress: CGFloat = 0 // 0~1 + @State private var breathingOpacity: CGFloat = 1.0 @State private var animationTask: Task? - private let movingSegmentLength: CGFloat = 0.28 - var body: some View { GeometryReader { geo in WithPerceptionTracking { @@ -68,6 +66,7 @@ struct WidgetAnimatedCapsule: View { let backgroundWidth = capsuleWidth let foregroundWidth = max(capsuleWidth - 4, 2) let padding = (backgroundWidth - foregroundWidth) / 2 + let foregroundHeight = capsuleHeight - padding * 2 ZStack { Capsule() @@ -91,87 +90,65 @@ struct WidgetAnimatedCapsule: View { } } .frame(width: backgroundWidth, height: capsuleHeight) - .animation(.easeInOut(duration: 0.14), value: isHovering) Capsule() - .fill(Color.accentColor.opacity(0.8)) + .fill(Color.white) .frame( width: foregroundWidth, - height: capsuleHeight * movingSegmentLength - ) - .opacity(store.isProcessing ? 1 : 0) - .position( - x: capsuleWidth / 2, - y: { - let height = capsuleHeight - padding * 2 - let base = padding - return base + height * (normalizedStart() + movingSegmentLength / 2) - }() + height: foregroundHeight ) - .animation(nil, value: store.isProcessing) - .animation(.easeInOut(duration: 0.14), value: isHovering) + .opacity({ + let base = store.isProcessing ? breathingOpacity : 0 + if isHovering { + return min(base + 0.5, 1.0) + } + return base + }()) + .blur(radius: 2) } .onAppear { - updateAnimationTask(isProcessing: store.isProcessing) + updateBreathingAnimation(isProcessing: store.isProcessing) } .onChange(of: store.isProcessing) { newValue in - updateAnimationTask(isProcessing: newValue) - } - .onChange(of: store.isContentEmpty) { _ in - if !store.isProcessing { - animatedProgress = store.isContentEmpty ? 0 : 1 - } + updateBreathingAnimation(isProcessing: newValue) } - .onChange(of: isHovering) { _ in } } } } - // 进度条起点 - private func normalizedStart() -> CGFloat { - let p = max(0, min(1, animatedProgress)) - return p * (1 - movingSegmentLength) - } - - // 动画任务 - private func updateAnimationTask(isProcessing: Bool) { + private func updateBreathingAnimation(isProcessing: Bool) { animationTask?.cancel() animationTask = nil if isProcessing { - animationTask = Task { [weak store] in - await MainActor.run { - animatedProgress = 0 - } + animationTask = Task { while !Task.isCancelled { await MainActor.run { - withAnimation(.linear(duration: 1.2)) { - animatedProgress = 1 + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 0.3 } } try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) if Task.isCancelled { break } - if !(store?.isProcessing ?? true) { break } + if !(store.isProcessing) { break } await MainActor.run { - withAnimation(.linear(duration: 1.2)) { - animatedProgress = 0 + withAnimation(.easeInOut(duration: 1.2)) { + breathingOpacity = 1.0 } } try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) if Task.isCancelled { break } - if !(store?.isProcessing ?? true) { break } + if !(store.isProcessing) { break } } } } else { withAnimation(.easeInOut(duration: 0.2)) { - animatedProgress = store.isContentEmpty ? 0 : 1 + breathingOpacity = 0 } } } } -// 下面的WidgetContextMenu和其它内容保持不变喵~ - struct WidgetContextMenu: View { @AppStorage(\.useGlobalChat) var useGlobalChat @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @@ -195,7 +172,7 @@ struct WidgetContextMenu: View { Button(action: { store.send(.openModificationButtonClicked) }) { - Text("Write or Modify Code") + Text("Write or Edit Code") } customCommandMenu() diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index d53d7867..2f70e0e3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -281,9 +281,13 @@ extension WidgetWindowsController { let focusElement = await xcodeInspector.focusedEditor?.element, let parent = focusElement.parent, let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let windowContainingScreen = NSScreen.screens - .first(where: { $0.frame.contains(frame.origin) }) + let screen = NSScreen.screens.first( + where: { $0.frame.origin == .zero } + ) ?? NSScreen.main, + let windowContainingScreen = NSScreen.screens.first(where: { + let flippedScreenFrame = $0.frame.flipped(relativeTo: screen.frame) + return flippedScreenFrame.contains(frame.origin) + }) { let positionMode = UserDefaults.shared .value(for: \.suggestionWidgetPositionMode) @@ -358,7 +362,7 @@ extension WidgetWindowsController { defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) ) } - + window = workspaceWindow frame = rect } @@ -700,7 +704,6 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] it.hasShadow = false @@ -718,10 +721,9 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(0) - it.hasShadow = true + it.hasShadow = false it.contentView = NSHostingView( rootView: WidgetView( store: store.scope( @@ -744,11 +746,10 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) it.hoveringLevel = widgetLevel(2) - it.hasShadow = true + it.hasShadow = false it.contentView = NSHostingView( rootView: SharedPanelView( store: store.scope( @@ -778,10 +779,11 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(2) - it.hasShadow = true + it.hasShadow = false + it.menu = nil + it.animationBehavior = .utilityWindow it.contentView = NSHostingView( rootView: SuggestionPanelView( store: store.scope( @@ -876,6 +878,8 @@ class WidgetWindow: CanBecomeKeyWindow { } var hoveringLevel: NSWindow.Level = widgetLevel(0) + + override var isFloatingPanel: Bool { true } var defaultCollectionBehavior: NSWindow.CollectionBehavior { [.fullScreenAuxiliary, .transient] @@ -930,3 +934,21 @@ func widgetLevel(_ addition: Int) -> NSWindow.Level { return .init(minimumWidgetLevel + addition) } +extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift index e7086d57..a2f814ac 100644 --- a/EditorExtension/PromptToCodeCommand.swift +++ b/EditorExtension/PromptToCodeCommand.swift @@ -4,7 +4,7 @@ import Foundation import XcodeKit class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { - var name: String { "Write or Modify Code" } + var name: String { "Write or Edit Code" } func perform( with invocation: XCSourceEditorCommandInvocation, diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index f30f4bc9..9107c97a 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -169,7 +169,7 @@ extension AppDelegate: NSMenuDelegate { menu.items.append(.text("Focused Element: N/A")) } - if let sourceEditor = inspector.focusedEditor { + if let sourceEditor = inspector.latestFocusedEditor { let label = sourceEditor.element.description menu.items .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png deleted file mode 100644 index 291eaac7..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png deleted file mode 100644 index 160db273..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@128w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png deleted file mode 100644 index 4fcd6278..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@16w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png deleted file mode 100644 index e31a8d3b..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@256w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png deleted file mode 100644 index ec264755..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@32w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w 1.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png deleted file mode 100644 index 4b760bc1..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@512w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png deleted file mode 100644 index 8d777985..00000000 Binary files a/ExtensionService/Assets.xcassets/AppIcon.appiconset/1024 x 1024 your icon@64w.png and /dev/null differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json index 56acb569..d3a89dc6 100644 --- a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,61 @@ { "images" : [ { - "filename" : "1024 x 1024 your icon@16w.png", + "filename" : "service-icon@16w.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w 1.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "1024 x 1024 your icon@32w.png", + "filename" : "service-icon@32w.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@64w.png", + "filename" : "service-icon@64w.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "1024 x 1024 your icon@128w.png", + "filename" : "service-icon@128w.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w 1.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "1024 x 1024 your icon@256w.png", + "filename" : "service-icon@256w.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w 1.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "1024 x 1024 your icon@512w.png", + "filename" : "service-icon@512w.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024 x 1024 your icon.png", + "filename" : "service-icon.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png new file mode 100644 index 00000000..29782a0f Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png new file mode 100644 index 00000000..c9479d72 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@128w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png new file mode 100644 index 00000000..f00e273e Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@16w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png new file mode 100644 index 00000000..0546b089 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@256w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png new file mode 100644 index 00000000..9f60ddf8 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@32w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png new file mode 100644 index 00000000..c00d18cf Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@512w.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png new file mode 100644 index 00000000..625b2717 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/service-icon@64w.png differ diff --git a/OverlayWindow/Package.swift b/OverlayWindow/Package.swift index f4edf91d..b875c713 100644 --- a/OverlayWindow/Package.swift +++ b/OverlayWindow/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "OverlayWindow", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "OverlayWindow", @@ -25,15 +25,15 @@ let package = Package( .product(name: "Toast", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "Logger", package: "Tool"), + .product(name: "DebounceFunction", package: "Tool"), .product(name: "Perception", package: "swift-perception"), .product(name: "Dependencies", package: "swift-dependencies"), ] ), .testTarget( name: "OverlayWindowTests", - dependencies: ["OverlayWindow"] + dependencies: ["OverlayWindow", .product(name: "DebounceFunction", package: "Tool")] ), ] ) - diff --git a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift index ff35d18a..48263e26 100644 --- a/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift +++ b/OverlayWindow/Sources/OverlayWindow/IDEWorkspaceWindowOverlayWindowController.swift @@ -1,6 +1,7 @@ import AppKit import AXExtension import AXNotificationStream +import DebounceFunction import Foundation import Perception import SwiftUI @@ -29,8 +30,9 @@ final class IDEWorkspaceWindowOverlayWindowController { let inspector: WorkspaceXcodeWindowInspector let contentProviders: [any IDEWorkspaceWindowOverlayWindowControllerContentProvider] let maskPanel: OverlayPanel - private var isDestroyed: Bool = false + var windowElement: AXUIElement private var axNotificationTask: Task? + let updateFrameThrottler = ThrottleRunner(duration: 0.2) init( inspector: WorkspaceXcodeWindowInspector, @@ -43,6 +45,7 @@ final class IDEWorkspaceWindowOverlayWindowController { self.application = application let contentProviders = contentProviderFactory(inspector, application) self.contentProviders = contentProviders + windowElement = inspector.uiElement let panel = OverlayPanel( contentRect: .init(x: 0, y: 0, width: 200, height: 200) @@ -61,42 +64,15 @@ final class IDEWorkspaceWindowOverlayWindowController { } } - let windowElement = inspector.uiElement - let stream = AXNotificationStream( - app: application, - element: windowElement, - notificationNames: kAXMovedNotification, kAXResizedNotification - ) - - axNotificationTask = Task { [weak self] in - for await notification in stream { - guard let panel = self?.maskPanel else { continue } - if Task.isCancelled { return } - switch notification.name { - case kAXMovedNotification, kAXResizedNotification: - if let rect = windowElement.rect { - panel.setTopLeftCoordinateFrame(rect, display: true) - } - default: continue - } - } - } - - if let rect = windowElement.rect { - panel.setTopLeftCoordinateFrame(rect, display: false) - } + observeWindowChange() } deinit { axNotificationTask?.cancel() - _ = withExtendedLifetime(self) { - Task { @MainActor in - precondition( - !self.isDestroyed, - "IDEWorkspaceWindowOverlayWindowController should be destroyed before deinit" - ) - } - } + } + + var isWindowClosed: Bool { + inspector.isInvalid } func access() { @@ -104,6 +80,14 @@ final class IDEWorkspaceWindowOverlayWindowController { maskPanel.level = overlayLevel(0) maskPanel.setIsVisible(true) maskPanel.orderFrontRegardless() + + // When macOS awakes from sleep, the AXUIElement reference may become invalid. + if windowElement.parent == nil { + windowElement = inspector.uiElement + observeWindowChange() + } else { + updateFrame() + } } func dim() { @@ -116,12 +100,48 @@ final class IDEWorkspaceWindowOverlayWindowController { } func destroy() { - axNotificationTask?.cancel() maskPanel.close() for contentProvider in contentProviders { contentProvider.destroy() } - isDestroyed = true + } + + private func observeWindowChange() { + axNotificationTask?.cancel() + + let stream = AXNotificationStream( + app: application, + element: windowElement, + notificationNames: + kAXMovedNotification, + kAXResizedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification + ) + + axNotificationTask = Task { [weak self] in + for await notification in stream { + guard let self else { return } + if Task.isCancelled { return } + switch notification.name { + case kAXMovedNotification, kAXResizedNotification: + await self.updateFrameThrottler.throttle { [weak self] in + await self?.updateFrame() + } + case kAXWindowMiniaturizedNotification: + self.hide() + default: continue + } + } + } + + updateFrame() + } + + private func updateFrame() { + if let rect = windowElement.rect { + maskPanel.setTopLeftCoordinateFrame(rect, display: false) + } } } diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayPanel.swift b/OverlayWindow/Sources/OverlayWindow/OverlayPanel.swift index 64dad58d..ed60f239 100644 --- a/OverlayWindow/Sources/OverlayWindow/OverlayPanel.swift +++ b/OverlayWindow/Sources/OverlayWindow/OverlayPanel.swift @@ -1,4 +1,5 @@ import AppKit +import Logger import Perception import SwiftUI @@ -23,7 +24,7 @@ public extension EnvironmentValues { } @MainActor -final class OverlayPanel: NSPanel { +public final class OverlayPanel: NSPanel { @MainActor @Perceptible final class PanelState { @@ -32,8 +33,9 @@ final class OverlayPanel: NSPanel { } let panelState: PanelState = .init() + private var _canBecomeKey = true - init( + public init( contentRect: NSRect, @ViewBuilder content: @escaping () -> Content ) { @@ -49,9 +51,10 @@ final class OverlayPanel: NSPanel { ) isReleasedWhenClosed = false - isOpaque = false + menu = nil + isOpaque = true backgroundColor = .clear - hasShadow = true + hasShadow = false alphaValue = 1.0 collectionBehavior = [.fullScreenAuxiliary] isFloatingPanel = true @@ -68,15 +71,21 @@ final class OverlayPanel: NSPanel { ) } - override var canBecomeKey: Bool { - return true + override public var canBecomeKey: Bool { + return _canBecomeKey } - override var canBecomeMain: Bool { + override public var canBecomeMain: Bool { return false } - func moveToActiveSpace() { + override public func setIsVisible(_ visible: Bool) { + _canBecomeKey = false + defer { _canBecomeKey = true } + super.setIsVisible(visible) + } + + public func moveToActiveSpace() { collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] Task { @MainActor in try await Task.sleep(nanoseconds: 50_000_000) @@ -85,18 +94,21 @@ final class OverlayPanel: NSPanel { } func setTopLeftCoordinateFrame(_ frame: CGRect, display: Bool) { - let screen = NSScreen.screens - .first(where: { $0.frame.intersects(frame) }) ?? NSScreen.main + let zeroScreen = NSScreen.screens.first { $0.frame.origin == .zero } + ?? NSScreen.primaryScreen ?? NSScreen.main let panelFrame = Self.convertAXRectToNSPanelFrame( axRect: frame, - forScreen: screen + forPrimaryScreen: zeroScreen ) panelState.windowFrame = frame panelState.windowFrameNSCoordinate = panelFrame setFrame(panelFrame, display: display) } - static func convertAXRectToNSPanelFrame(axRect: CGRect, forScreen screen: NSScreen?) -> CGRect { + static func convertAXRectToNSPanelFrame( + axRect: CGRect, + forPrimaryScreen screen: NSScreen? + ) -> CGRect { guard let screen = screen else { return .zero } let screenFrame = screen.frame let flippedY = screenFrame.origin.y + screenFrame.size @@ -119,18 +131,6 @@ final class OverlayPanel: NSPanel { ZStack { Rectangle().fill(.green.opacity(debugOverlayPanel ? 0.1 : 0)) .allowsHitTesting(false) - .overlay(alignment: .topTrailing) { - HStack { - Button(action: { - debugOverlayPanel.toggle() - }) { - Image(systemName: "eye") - .foregroundColor(debugOverlayPanel ? .green : .red) - .padding() - } - .buttonStyle(.plain) - } - } content() .environment(\.overlayFrame, panelState.windowFrame) .environment(\.overlayDebug, debugOverlayPanel) @@ -150,3 +150,35 @@ func overlayLevel(_ addition: Int) -> NSWindow.Level { return .init(minimumWidgetLevel + addition) } +public extension CGRect { + func flipped(relativeTo reference: CGRect) -> CGRect { + let flippedOrigin = CGPoint( + x: origin.x, + y: reference.height - origin.y - height + ) + return CGRect(origin: flippedOrigin, size: size) + } + + func relative(to reference: CGRect) -> CGRect { + let relativeOrigin = CGPoint( + x: origin.x - reference.origin.x, + y: origin.y - reference.origin.y + ) + return CGRect(origin: relativeOrigin, size: size) + } +} + +public extension NSScreen { + var isPrimary: Bool { + let id = deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + + static var primaryScreen: NSScreen? { + NSScreen.screens.first { + let id = $0.deviceDescription[.init("NSScreenNumber")] as? CGDirectDisplayID + return id == CGMainDisplayID() + } + } +} + diff --git a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift index a35b2d69..f4dbaa73 100644 --- a/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift +++ b/OverlayWindow/Sources/OverlayWindow/OverlayWindowController.swift @@ -1,4 +1,5 @@ import AppKit +import DebounceFunction import Foundation import Perception import XcodeInspector @@ -14,10 +15,12 @@ public final class OverlayWindowController { static var ideWindowOverlayWindowControllerContentProviderFactories: [IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory] = [] - var ideWindowOverlayWindowControllers: [URL: IDEWorkspaceWindowOverlayWindowController] = [:] + var ideWindowOverlayWindowControllers = + [ObjectIdentifier: IDEWorkspaceWindowOverlayWindowController]() var updateWindowStateTask: Task? - @MainActor + let windowUpdateThrottler = ThrottleRunner(duration: 0.2) + lazy var fullscreenDetector = { let it = NSWindow( contentRect: .zero, @@ -40,17 +43,6 @@ public final class OverlayWindowController { observeEvents() _ = fullscreenDetector } - - public func removeController(for url: URL) { - if let controller = ideWindowOverlayWindowControllers[url] { - controller.destroy() - ideWindowOverlayWindowControllers.removeValue(forKey: url) - } - } - - public func removeAllControllers() { - ideWindowOverlayWindowControllers.removeAll() - } public nonisolated static func registerIDEWorkspaceWindowOverlayWindowControllerContentProviderFactory( _ factory: @escaping IDEWorkspaceWindowOverlayWindowControllerContentProviderFactory @@ -91,9 +83,7 @@ private extension OverlayWindowController { let windowInspector = XcodeInspector.shared .focusedWindow as? WorkspaceXcodeWindowInspector { - let workspaceURL = windowInspector.workspaceURL createNewIDEOverlayWindowController( - for: workspaceURL, inspector: windowInspector, application: app.runningApplication ) @@ -108,55 +98,18 @@ private extension OverlayWindowController { guard let self else { return } Task { @MainActor in defer { self.observeWindowChange() } - - guard XcodeInspector.shared.activeApplication?.isXcode ?? false else { - for (_, controller) in self.ideWindowOverlayWindowControllers { - controller.dim() - } - return - } - - guard let app = XcodeInspector.shared.activeXcode else { - for (_, controller) in self.ideWindowOverlayWindowControllers { - controller.hide() - } - return - } - - let windowInspector = XcodeInspector.shared.focusedWindow - if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector { - let workspaceURL = ideWindowInspector.workspaceURL - // Workspace window is active - // Hide all controllers first - for (url, controller) in self.ideWindowOverlayWindowControllers { - if url != workspaceURL { - controller.hide() - } - } - if let controller = self.ideWindowOverlayWindowControllers[workspaceURL] { - controller.access() - } else { - self.createNewIDEOverlayWindowController( - for: workspaceURL, - inspector: ideWindowInspector, - application: app.runningApplication - ) - } - } else { - // Not a workspace window, dim all controllers - for (_, controller) in self.ideWindowOverlayWindowControllers { - controller.dim() - } + await self.windowUpdateThrottler.throttle { [weak self] in + await self?.handleOverlayStatusChange() } } } } func createNewIDEOverlayWindowController( - for workspaceURL: URL, inspector: WorkspaceXcodeWindowInspector, application: NSRunningApplication ) { + let id = ObjectIdentifier(inspector) let newController = IDEWorkspaceWindowOverlayWindowController( inspector: inspector, application: application, @@ -167,14 +120,22 @@ private extension OverlayWindowController { } ) newController.access() - ideWindowOverlayWindowControllers[workspaceURL] = newController + ideWindowOverlayWindowControllers[id] = newController + } + + func removeIDEOverlayWindowController(for id: ObjectIdentifier) { + if let controller = ideWindowOverlayWindowControllers[id] { + controller.destroy() + } + ideWindowOverlayWindowControllers[id] = nil } func handleSpaceChange() async { let windowInspector = XcodeInspector.shared.focusedWindow guard let activeWindowController = { if let windowInspector = windowInspector as? WorkspaceXcodeWindowInspector { - return ideWindowOverlayWindowControllers[windowInspector.workspaceURL] + let id = ObjectIdentifier(windowInspector) + return ideWindowOverlayWindowControllers[id] } else { return nil } @@ -191,5 +152,55 @@ private extension OverlayWindowController { activeWindowController.maskPanel.orderFrontRegardless() } } + + func handleOverlayStatusChange() { + guard XcodeInspector.shared.activeApplication?.isXcode ?? false else { + var closedControllers: [ObjectIdentifier] = [] + for (id, controller) in ideWindowOverlayWindowControllers { + if controller.isWindowClosed { + controller.dim() + closedControllers.append(id) + } else { + controller.dim() + } + } + for id in closedControllers { + removeIDEOverlayWindowController(for: id) + } + return + } + + guard let app = XcodeInspector.shared.activeXcode else { + for (_, controller) in ideWindowOverlayWindowControllers { + controller.hide() + } + return + } + + let windowInspector = XcodeInspector.shared.focusedWindow + if let ideWindowInspector = windowInspector as? WorkspaceXcodeWindowInspector { + let objectID = ObjectIdentifier(ideWindowInspector) + // Workspace window is active + // Hide all controllers first + for (id, controller) in ideWindowOverlayWindowControllers { + if id != objectID { + controller.hide() + } + } + if let controller = ideWindowOverlayWindowControllers[objectID] { + controller.access() + } else { + createNewIDEOverlayWindowController( + inspector: ideWindowInspector, + application: app.runningApplication + ) + } + } else { + // Not a workspace window, dim all controllers + for (_, controller) in ideWindowOverlayWindowControllers { + controller.dim() + } + } + } } diff --git a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift index 4ecd04d1..98b0a5bf 100644 --- a/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift +++ b/OverlayWindow/Tests/OverlayWindowTests/WindowTests.swift @@ -1,5 +1,4 @@ import Testing -@testable import Window @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. diff --git a/README.md b/README.md index 310bd3f6..c4066a45 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ This feature is recommended when you need to update a specific piece of code. So #### Commands -- Write or Modify Code: Open a modification window, where you can use natural language to write or edit selected code. +- Write or Edit Code: Open a modification window, where you can use natural language to write or edit selected code. - Accept Modification: Accept the result of modification. ### Custom Commands diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index f015c57e..c9ebe525 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -24,86 +24,86 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Core", - "identifier" : "ChatServiceTests", - "name" : "ChatServiceTests" + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "OpenAIServiceTests", - "name" : "OpenAIServiceTests" + "identifier" : "ActiveDocumentChatContextCollectorTests", + "name" : "ActiveDocumentChatContextCollectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionInjectorTests", - "name" : "SuggestionInjectorTests" + "identifier" : "CodeDiffTests", + "name" : "CodeDiffTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "WebSearchServiceTests", - "name" : "WebSearchServiceTests" + "containerPath" : "container:Core", + "identifier" : "ServiceUpdateMigrationTests", + "name" : "ServiceUpdateMigrationTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "GitHubCopilotServiceTests", - "name" : "GitHubCopilotServiceTests" + "identifier" : "ASTParserTests", + "name" : "ASTParserTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "JoinJSONTests", - "name" : "JoinJSONTests" + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "CodeDiffTests", - "name" : "CodeDiffTests" + "containerPath" : "container:Core", + "identifier" : "PromptToCodeServiceTests", + "name" : "PromptToCodeServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionBasicTests", - "name" : "SuggestionBasicTests" + "identifier" : "KeychainTests", + "name" : "KeychainTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "FocusedCodeFinderTests", - "name" : "FocusedCodeFinderTests" + "identifier" : "JoinJSONTests", + "name" : "JoinJSONTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "ASTParserTests", - "name" : "ASTParserTests" + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "PromptToCodeServiceTests", - "name" : "PromptToCodeServiceTests" + "containerPath" : "container:OverlayWindow", + "identifier" : "OverlayWindowTests", + "name" : "OverlayWindowTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "TokenEncoderTests", - "name" : "TokenEncoderTests" + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" } }, { @@ -116,64 +116,71 @@ { "target" : { "containerPath" : "container:Tool", - "identifier" : "SharedUIComponentsTests", - "name" : "SharedUIComponentsTests" + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "OpenAIServiceTests", + "name" : "OpenAIServiceTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "KeyBindingManagerTests", - "name" : "KeyBindingManagerTests" + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" } }, { "target" : { - "containerPath" : "container:Tool", - "identifier" : "XcodeInspectorTests", - "name" : "XcodeInspectorTests" + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "ActiveDocumentChatContextCollectorTests", - "name" : "ActiveDocumentChatContextCollectorTests" + "identifier" : "FocusedCodeFinderTests", + "name" : "FocusedCodeFinderTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "ServiceUpdateMigrationTests", - "name" : "ServiceUpdateMigrationTests" + "containerPath" : "container:Tool", + "identifier" : "WebSearchServiceTests", + "name" : "WebSearchServiceTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "SuggestionProviderTests", - "name" : "SuggestionProviderTests" + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" } }, { "target" : { "containerPath" : "container:Core", - "identifier" : "ServiceTests", - "name" : "ServiceTests" + "identifier" : "ChatServiceTests", + "name" : "ChatServiceTests" } }, { "target" : { - "containerPath" : "container:Core", - "identifier" : "SuggestionWidgetTests", - "name" : "SuggestionWidgetTests" + "containerPath" : "container:Tool", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" } }, { "target" : { "containerPath" : "container:Tool", - "identifier" : "KeychainTests", - "name" : "KeychainTests" + "identifier" : "TokenEncoderTests", + "name" : "TokenEncoderTests" } } ], diff --git a/Tool/Package.swift b/Tool/Package.swift index 0dcdfc6f..f303e44c 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -231,6 +231,7 @@ let package = Package( ), ] ), + .testTarget(name: "ModificationBasicTests", dependencies: ["ModificationBasic"]), .target( name: "PromptToCodeCustomization", diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 0869dae8..e54bfaff 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -84,11 +84,11 @@ public extension AXUIElement { var isHidden: Bool { (try? copyValue(key: kAXHiddenAttribute)) ?? false } - + var debugDescription: String { "<\(title)> <\(description)> <\(label)> (\(role):\(roleDescription)) [\(identifier)] \(rect ?? .zero) \(children.count) children" } - + var debugEnumerateChildren: String { var result = "> " + debugDescription + "\n" result += children.map { @@ -98,7 +98,7 @@ public extension AXUIElement { }.joined(separator: "\n") return result } - + var debugEnumerateParents: String { var chain: [String] = [] chain.append("* " + debugDescription) @@ -166,7 +166,7 @@ public extension AXUIElement { var isFullScreen: Bool { (try? copyValue(key: "AXFullScreen")) ?? false } - + var windowID: CGWindowID? { var identifier: CGWindowID = 0 let error = AXUIElementGetWindow(self, &identifier) @@ -265,7 +265,7 @@ public extension AXUIElement { fatalError("AXUIElement.children: Exceeding recommended depth.") } #endif - + var all = [AXUIElement]() for child in children { if match(child) { all.append(child) } @@ -282,11 +282,19 @@ public extension AXUIElement { return parent.firstParent(where: match) } - func firstChild(depth: Int = 0, where match: (AXUIElement) -> Bool) -> AXUIElement? { + func firstChild( + depth: Int = 0, + maxDepth: Int = 50, + where match: (AXUIElement) -> Bool + ) -> AXUIElement? { #if DEBUG - if depth >= 50 { + if depth > maxDepth { fatalError("AXUIElement.firstChild: Exceeding recommended depth.") } + #else + if depth > maxDepth { + return nil + } #endif for child in children { if match(child) { return child } @@ -313,11 +321,11 @@ public extension AXUIElement { } public extension AXUIElement { - enum SearchNextStep { + enum SearchNextStep { case skipDescendants - case skipSiblings + case skipSiblings(Info) case skipDescendantsAndSiblings - case continueSearching + case continueSearching(Info) case stopSearching } @@ -327,26 +335,41 @@ public extension AXUIElement { /// **performance of Xcode**. Please make sure to skip as much as possible. /// /// - todo: Make it not recursive. - func traverse(_ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep) { + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + info: Info, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int, _ info: Info) -> SearchNextStep + ) { + #if DEBUG + var count = 0 +// let startDate = Date() + #endif func _traverse( element: AXUIElement, level: Int, - handle: (AXUIElement, Int) -> SearchNextStep - ) -> SearchNextStep { - let nextStep = handle(element, level) + info: Info, + handle: (AXUIElement, Int, Info) -> SearchNextStep + ) -> SearchNextStep { + #if DEBUG + count += 1 + #endif + let nextStep = handle(element, level, info) switch nextStep { case .stopSearching: return .stopSearching - case .skipDescendants: return .continueSearching - case .skipDescendantsAndSiblings: return .skipSiblings - case .continueSearching, .skipSiblings: - for child in element.children { - switch _traverse(element: child, level: level + 1, handle: handle) { + case .skipDescendants: return .continueSearching(info) + case .skipDescendantsAndSiblings: return .skipSiblings(info) + case let .continueSearching(info), let .skipSiblings(info): + loop: for child in access(element) { + switch _traverse(element: child, level: level + 1, info: info, handle: handle) { case .skipSiblings, .skipDescendantsAndSiblings: - break + break loop case .stopSearching: return .stopSearching case .continueSearching, .skipDescendants: - continue + continue loop } } @@ -354,7 +377,37 @@ public extension AXUIElement { } } - _ = _traverse(element: self, level: 0, handle: handle) + _ = _traverse(element: self, level: 0, info: info, handle: handle) + + #if DEBUG +// let duration = Date().timeIntervalSince(startDate) +// .formatted(.number.precision(.fractionLength(0...4))) +// Logger.service.debug( +// "AXUIElement.traverse count: \(count), took \(duration) seconds", +// file: file, +// line: line, +// function: function +// ) + #endif + } + + /// Traversing the element tree. + /// + /// - important: Traversing the element tree is resource consuming and will affect the + /// **performance of Xcode**. Please make sure to skip as much as possible. + /// + /// - todo: Make it not recursive. + func traverse( + access: (AXUIElement) -> [AXUIElement] = { $0.children }, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function, + _ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep + ) { + traverse(access: access, info: (), file: file, line: line, function: function) { + element, level, _ in + handle(element, level) + } } } diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 89fca015..b361f8ae 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -133,9 +133,12 @@ public final class AXNotificationStream: AsyncSequence { .error("AXObserver: Accessibility API disabled, will try again later") retry -= 1 case .invalidUIElement: + // It's possible that the UI element is not ready yet. + // + // Especially when you retrieve an element right after macOS is + // awaken from sleep. Logger.service .error("AXObserver: Invalid UI element, notification name \(name)") - pendingRegistrationNames.remove(name) case .invalidUIElementObserver: Logger.service.error("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 763233b9..1b6b835d 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -33,17 +33,20 @@ public struct ChatAgentRequest { public var history: [ChatMessage] public var references: [ChatMessage.Reference] public var topics: [ChatMessage.Reference] + public var agentInstructions: String? = nil public init( text: String, history: [ChatMessage], references: [ChatMessage.Reference], - topics: [ChatMessage.Reference] + topics: [ChatMessage.Reference], + agentInstructions: String? = nil ) { self.text = text self.history = history self.references = references self.topics = topics + self.agentInstructions = agentInstructions } } diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 11e46f73..2a5a4af0 100644 --- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -27,6 +27,7 @@ public enum ChatGPTFunctionResultUserReadableContent: Sendable { case text(String) case list([ListItem]) + case searchResult([ListItem], queries: [String]) } public protocol ChatGPTFunctionResult { diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift index e6e0ef91..cd5977a8 100644 --- a/Tool/Sources/ChatBasic/ChatPlugin.swift +++ b/Tool/Sources/ChatBasic/ChatPlugin.swift @@ -1,6 +1,6 @@ import Foundation -public struct ChatPluginRequest { +public struct ChatPluginRequest: Sendable { public var text: String public var arguments: [String] public var history: [ChatMessage] diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index 295a4467..29936bf8 100644 --- a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.46.3" + static let latestSupportedVersion = "1.48.2" static let minimumSupportedVersion = "1.20.0" public init() {} diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift index df296cc8..bb97d82f 100644 --- a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -46,18 +46,27 @@ public extension AsyncSequence { /// /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. func timedDebounce( - for duration: TimeInterval + for duration: TimeInterval, + reducer: @escaping @Sendable (Element, Element) -> Element ) -> AsyncThrowingStream { return AsyncThrowingStream { continuation in Task { - let function = TimedDebounceFunction(duration: duration) { value in - continuation.yield(value) - } + let storage = TimedDebounceStorage() + var lastTimeStamp = Date() do { for try await value in self { - await function(value) + await storage.reduce(value, reducer: reducer) + let now = Date() + if now.timeIntervalSince(lastTimeStamp) >= duration { + lastTimeStamp = now + if let value = await storage.consume() { + continuation.yield(value) + } + } + } + if let value = await storage.consume() { + continuation.yield(value) } - await function.finish() continuation.finish() } catch { continuation.finish(throwing: error) @@ -67,3 +76,19 @@ public extension AsyncSequence { } } +private actor TimedDebounceStorage { + var value: Element? + func reduce(_ value: Element, reducer: (Element, Element) -> Element) async { + if let existing = self.value { + self.value = reducer(existing, value) + } else { + self.value = value + } + } + + func consume() -> Element? { + defer { value = nil } + return value + } +} + diff --git a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift index 9f648612..891c8301 100644 --- a/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift +++ b/Tool/Sources/CustomCommandTemplateProcessor/CustomCommandTemplateProcessor.swift @@ -39,7 +39,7 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() async -> EditorInformation { - let editorContent = await XcodeInspector.shared.focusedEditor?.getContent() + let editorContent = await XcodeInspector.shared.latestFocusedEditor?.getContent() let documentURL = await XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 8ff625f8..606499a4 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -1,9 +1,11 @@ import BuiltinExtension import CopilotForXcodeKit +import Dependencies import Foundation import LanguageServerProtocol import Logger import Preferences +import Toast import Workspace public final class GitHubCopilotExtension: BuiltinExtension { @@ -20,6 +22,8 @@ public final class GitHubCopilotExtension: BuiltinExtension { extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } + @Dependency(\.toastController) var toast + let workspacePool: WorkspacePool let serviceLocator: ServiceLocatorType @@ -49,6 +53,16 @@ public final class GitHubCopilotExtension: BuiltinExtension { let content = try String(contentsOf: documentURL, encoding: .utf8) guard let service = await serviceLocator.getService(from: workspace) else { return } try await service.notifyOpenTextDocument(fileURL: documentURL, content: content) + } catch let error as ServerError { + let e = GitHubCopilotError.languageServerError(error) + Logger.gitHubCopilot.error(e.localizedDescription) + + switch error { + case .serverUnavailable, .serverError: + toast.toast(content: e.localizedDescription, type: .error, duration: 10.0) + default: + break + } } catch { Logger.gitHubCopilot.error(error.localizedDescription) } @@ -295,7 +309,7 @@ extension GitHubCopilotExtension { var id: String var capabilities: Capability } - + struct Body: Decodable { var data: [Model] } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift index e893c133..405a5402 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -1,8 +1,8 @@ +import Dependencies import Foundation import Logger -import Workspace import Toast -import Dependencies +import Workspace public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { enum Error: Swift.Error, LocalizedError { @@ -14,7 +14,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @Dependency(\.toast) var toast let installationManager = GitHubCopilotInstallationManager() @@ -32,6 +32,10 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { return nil } catch { Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + toast( + "Failed to start GitHub Copilot language server: \(error.localizedDescription)", + .error + ) return nil } } @@ -74,7 +78,7 @@ public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { } } } - + @GitHubCopilotSuggestionActor func updateLanguageServerIfPossible() async { guard !GitHubCopilotInstallationManager.isInstalling else { return } diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index 2971786e..edf59a50 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,7 +6,7 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "18f485d892b56b311fd752039d6977333ebc2a0f" + let commitHash = "f89e977c87180519ba3b942200e3d05b17b1e2fc" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } @@ -14,7 +14,7 @@ public struct GitHubCopilotInstallationManager { /// The GitHub's version has quite a lot of changes about `watchedFiles` since the following /// commit. /// https://github.com/github/CopilotForXcode/commit/a50045aa3ab3b7d532cadf40c4c10bed32f81169#diff-678798cf677bcd1ce276809cfccd33da9ff594b1b0c557180210a4ed2bd27ffa - static let latestSupportedVersion = "1.48.0" + static let latestSupportedVersion = "1.57.0" static let minimumSupportedVersion = "1.32.0" public init() {} @@ -151,7 +151,15 @@ public struct GitHubCopilotInstallationManager { return } - let lspURL = gitFolderURL.appendingPathComponent("dist") + let lspURL = { + let caseA = gitFolderURL.appendingPathComponent("dist") + if FileManager.default.fileExists(atPath: caseA.path) { + return caseA + } + return gitFolderURL + .appendingPathComponent("copilot-language-server") + .appendingPathComponent("dist") + }() let copilotURL = urls.executableURL.appendingPathComponent("copilot") if !FileManager.default.fileExists(atPath: copilotURL.path) { diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 2f874200..486ddd92 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -83,7 +83,7 @@ enum GitHubCopilotError: Error, LocalizedError { case let .clientDataUnavailable(error): return "Language server error: Client data unavailable: \(error)" case .serverUnavailable: - return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough." + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough (v22.0+)." case .missingExpectedParameter: return "Language server error: Missing expected parameter" case .missingExpectedResult: @@ -523,11 +523,23 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, // And sometimes the language server's content was not up to date and may generate // weird result when the cursor position exceeds the line. let task = Task { @GitHubCopilotSuggestionActor in - try await notifyChangeTextDocument( - fileURL: fileURL, - content: content, - version: 1 - ) + do { + try await notifyChangeTextDocument( + fileURL: fileURL, + content: content, + version: 1 + ) + } catch let error as ServerError { + switch error { + case .serverUnavailable: + throw SuggestionServiceError + .notice(GitHubCopilotError.languageServerError(error)) + default: + throw error + } + } catch { + throw error + } do { try Task.checkCancellation() diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 2d791376..58d280f0 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -53,7 +53,11 @@ public final class Logger { osLogType = .error } + #if DEBUG + os_log("%{public}@", log: osLog, type: osLogType, "\(file):\(line) \(function)\n\n\(message)" as CVarArg) + #else os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) + #endif } public func debug( diff --git a/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift new file mode 100644 index 00000000..bb3a71e9 --- /dev/null +++ b/Tool/Sources/ModificationBasic/ExplanationThenCodeStreamParser.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Parse a stream that contains explanation followed by a code block. +public actor ExplanationThenCodeStreamParser { + enum State { + case explanation + case code + case codeOpening + case codeClosing + } + + public enum Fragment: Sendable { + case explanation(String) + case code(String) + } + + struct Buffer { + var content: String = "" + } + + var _buffer: Buffer = .init() + var isAtBeginning = true + var buffer: String { _buffer.content } + var state: State = .explanation + let fullCodeDelimiter = "```" + + public init() {} + + private func appendBuffer(_ character: Character) { + _buffer.content.append(character) + } + + private func appendBuffer(_ content: String) { + _buffer.content += content + } + + private func resetBuffer() { + _buffer.content = "" + } + + func flushBuffer() -> String? { + if buffer.isEmpty { return nil } + guard let targetIndex = _buffer.content.lastIndex(where: { $0 != "`" && !$0.isNewline }) + else { return nil } + let prefix = _buffer.content[...targetIndex] + if prefix.isEmpty { return nil } + let nextIndex = _buffer.content.index( + targetIndex, + offsetBy: 1, + limitedBy: _buffer.content.endIndex + ) ?? _buffer.content.endIndex + + if nextIndex == _buffer.content.endIndex { + _buffer.content = "" + } else { + _buffer.content = String( + _buffer.content[nextIndex...] + ) + } + + // If we flushed something, we are no longer at the beginning + isAtBeginning = false + return String(prefix) + } + + func flushBufferIfNeeded(into results: inout [Fragment]) { + switch state { + case .explanation: + if let flushed = flushBuffer() { + results.append(.explanation(flushed)) + } + case .code: + if let flushed = flushBuffer() { + results.append(.code(flushed)) + } + case .codeOpening, .codeClosing: + break + } + } + + public func yield(_ fragment: String) -> [Fragment] { + var results: [Fragment] = [] + + func flushBuffer() { + flushBufferIfNeeded(into: &results) + } + + for character in fragment { + switch state { + case .explanation: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.explanation(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let shouldOpenCodeBlock: Bool = { + guard buffer.hasSuffix(fullCodeDelimiter) + else { return false } + if isAtBeginning { return true } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return true + } + return false + }() + // if we meet a code delimiter while in explanation state, + // it means we are opening a code block + if shouldOpenCodeBlock { + results.append(.explanation( + String(buffer.dropLast(fullCodeDelimiter.count)) + .trimmingTrailingCharacters(in: .whitespacesAndNewlines) + )) + resetBuffer() + state = .codeOpening + } + } else { + // Otherwise, the backtick is probably part of the explanation. + forceFlush() + appendBuffer(character) + } + case let char where char.isNewline: + // we keep the trailing new lines in case they are right + // ahead of the code block that should be ignored. + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .code: + func forceFlush() { + if !buffer.isEmpty { + isAtBeginning = false + results.append(.code(buffer)) + resetBuffer() + } + } + + switch character { + case "`": + if let last = buffer.last, last == "`" || last.isNewline { + flushBuffer() + // if we are seeing the pattern of "\n`" or "``" + // that mean we may be hitting a code delimiter + appendBuffer(character) + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet a code delimiter while in code state, + // // it means we are closing the code block + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + state = .codeClosing + } + } else { + // Otherwise, the backtick is probably part of the code. + forceFlush() + appendBuffer(character) + } + + case let char where char.isNewline: + if let last = buffer.last, last.isNewline { + flushBuffer() + appendBuffer(character) + } else { + forceFlush() + appendBuffer(character) + } + default: + appendBuffer(character) + } + case .codeOpening: + // skip the code block fence + if character.isNewline { + state = .code + } + case .codeClosing: + appendBuffer(character) + switch character { + case "`": + let possibleClosingDelimiter: String? = { + guard buffer.hasSuffix(fullCodeDelimiter) else { return nil } + let temp = String(buffer.dropLast(fullCodeDelimiter.count)) + if let last = temp.last, last.isNewline { + return "\(last)\(fullCodeDelimiter)" + } + return nil + }() + // if we meet another code delimiter while in codeClosing state, + // it means the previous code delimiter was part of the code + if let possibleClosingDelimiter { + results.append(.code( + String(buffer.dropLast(possibleClosingDelimiter.count)) + )) + resetBuffer() + appendBuffer(possibleClosingDelimiter) + } + default: + break + } + } + } + + flushBuffer() + + return results + } + + public func finish() -> [Fragment] { + guard !buffer.isEmpty else { return [] } + + var results: [Fragment] = [] + switch state { + case .explanation: + results.append( + .explanation(buffer.trimmingTrailingCharacters(in: .whitespacesAndNewlines)) + ) + case .code: + results.append(.code(buffer)) + case .codeClosing: + break + case .codeOpening: + break + } + resetBuffer() + + return results + } +} + +extension String { + func trimmingTrailingCharacters(in characterSet: CharacterSet) -> String { + guard !isEmpty else { + return "" + } + var unicodeScalars = unicodeScalars + while let scalar = unicodeScalars.last { + if !characterSet.contains(scalar) { + return String(unicodeScalars) + } + unicodeScalars.removeLast() + } + return "" + } +} + diff --git a/Tool/Sources/ModificationBasic/ModificationAgent.swift b/Tool/Sources/ModificationBasic/ModificationAgent.swift index 69a409f7..a29224af 100644 --- a/Tool/Sources/ModificationBasic/ModificationAgent.swift +++ b/Tool/Sources/ModificationBasic/ModificationAgent.swift @@ -5,6 +5,7 @@ import SuggestionBasic public enum ModificationAgentResponse { case code(String) + case explanation(String) } public struct ModificationAgentRequest { diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 0b41c053..223eab79 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -7,6 +7,7 @@ import JoinJSON import Logger import Preferences +#warning("Update the definitions") /// https://docs.anthropic.com/claude/reference/messages_post public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { /// https://docs.anthropic.com/en/docs/about-claude/models @@ -44,7 +45,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - enum MessageRole: String, Codable { + public enum MessageRole: String, Codable { case user case assistant @@ -127,7 +128,7 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet var stop_sequence: String? } - public struct RequestBody: Encodable, Equatable { + public struct RequestBody: Codable, Equatable { public struct CacheControl: Codable, Equatable, Sendable { public enum CacheControlType: String, Codable, Equatable, Sendable { case ephemeral @@ -136,33 +137,33 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public var type: CacheControlType = .ephemeral } - struct MessageContent: Encodable, Equatable { - enum MessageContentType: String, Encodable, Equatable { + public struct MessageContent: Codable, Equatable { + public enum MessageContentType: String, Codable, Equatable { case text case image } - struct ImageSource: Encodable, Equatable { - var type: String = "base64" + public struct ImageSource: Codable, Equatable { + public var type: String = "base64" /// currently support the base64 source type for images, /// and the image/jpeg, image/png, image/gif, and image/webp media types. - var media_type: String = "image/jpeg" - var data: String + public var media_type: String = "image/jpeg" + public var data: String } - var type: MessageContentType - var text: String? - var source: ImageSource? - var cache_control: CacheControl? + public var type: MessageContentType + public var text: String? + public var source: ImageSource? + public var cache_control: CacheControl? } - struct Message: Encodable, Equatable { + public struct Message: Codable, Equatable { /// The role of the message. - var role: MessageRole + public var role: MessageRole /// The content of the message. - var content: [MessageContent] + public var content: [MessageContent] - mutating func appendText(_ text: String) { + public mutating func appendText(_ text: String) { var otherContents = [MessageContent]() var existedText = "" for existed in content { @@ -182,26 +183,26 @@ public actor ClaudeChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } - struct SystemPrompt: Encodable, Equatable { - let type = "text" - var text: String - var cache_control: CacheControl? + public struct SystemPrompt: Codable, Equatable { + public var type = "text" + public var text: String + public var cache_control: CacheControl? } - struct Tool: Encodable, Equatable { - var name: String - var description: String - var input_schema: JSONSchemaValue + public struct Tool: Codable, Equatable { + public var name: String + public var description: String + public var input_schema: JSONSchemaValue } - var model: String - var system: [SystemPrompt] - var messages: [Message] - var temperature: Double? - var stream: Bool? - var stop_sequences: [String]? - var max_tokens: Int - var tools: [RequestBody.Tool]? + public var model: String + public var system: [SystemPrompt] + public var messages: [Message] + public var temperature: Double? + public var stream: Bool? + public var stop_sequences: [String]? + public var max_tokens: Int + public var tools: [RequestBody.Tool]? } var apiKey: String @@ -521,5 +522,53 @@ extension ClaudeChatCompletionsService.RequestBody { stop_sequences = body.stop max_tokens = body.maxTokens ?? 4000 } + + func formalized() -> ChatCompletionsRequestBody { + return .init( + model: model, + messages: system.map { system in + let convertedMessage = ChatCompletionsRequestBody.Message( + role: .system, + content: system.text, + cacheIfPossible: system.cache_control != nil + ) + return convertedMessage + } + messages.map { message in + var convertedMessage = ChatCompletionsRequestBody.Message( + role: message.role == .user ? .user : .assistant, + content: "", + cacheIfPossible: message.content.contains(where: { $0.cache_control != nil }) + ) + for messageContent in message.content { + switch messageContent.type { + case .text: + if let text = messageContent.text { + convertedMessage.content += text + } + case .image: + if let source = messageContent.source { + convertedMessage.images.append( + .init( + base64EncodeData: source.data, + format: { + switch source.media_type { + case "image/png": return .png + case "image/gif": return .gif + default: return .jpeg + } + }() + ) + ) + } + } + } + return convertedMessage + }, + temperature: temperature, + stream: stream, + stop: stop_sequences, + maxTokens: max_tokens + ) + } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 97e12b36..93c5987d 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -74,10 +74,12 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet case assistant case function case tool + case developer var formalized: ChatCompletionsRequestBody.Message.Role { switch self { case .system: return .system + case .developer: return .system case .user: return .user case .assistant: return .assistant case .function: return .tool @@ -271,10 +273,10 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public struct RequestBody: Codable, Equatable { public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl - + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { public var type: String - + public init(type: String = "ephemeral") { self.type = type } @@ -435,6 +437,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet errors.append(error) } + do { // Null + _ = try container.decode([ContentPart]?.self) + self = .contentParts([]) + return + } catch { + errors.append(error) + } + struct E: Error, LocalizedError { let errors: [Error] @@ -785,9 +795,8 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } } - + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { - guard model.format == .gitHubCopilot else { return } if model.info.supportsImage { request.setValue("true", forHTTPHeaderField: "copilot-vision-request") } @@ -1001,7 +1010,7 @@ extension OpenAIChatCompletionsService.RequestBody { ) { if supportsMultipartMessageContent { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: let newParts = Self.convertContentPart( content: content, images: images, @@ -1021,7 +1030,7 @@ extension OpenAIChatCompletionsService.RequestBody { } } else { switch message.role { - case .system, .assistant, .user: + case .system, .developer, .assistant, .user: if case let .text(existingText) = message.content { message.content = .text(existingText + "\n\n" + content) } else { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift new file mode 100644 index 00000000..818c4616 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/OpenAIResponsesRawService.swift @@ -0,0 +1,235 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import JoinJSON +import Logger +import Preferences + +/// https://platform.openai.com/docs/api-reference/responses/create +public actor OpenAIResponsesRawService { + struct CompletionAPIError: Error, Decodable, LocalizedError { + struct ErrorDetail: Decodable { + var message: String + var type: String? + var param: String? + var code: String? + } + + struct MistralAIErrorMessage: Decodable { + struct Detail: Decodable { + var msg: String? + } + + var message: String? + var msg: String? + var detail: [Detail]? + } + + enum Message { + case raw(String) + case mistralAI(MistralAIErrorMessage) + } + + var error: ErrorDetail? + var message: Message + + var errorDescription: String? { + if let message = error?.message { return message } + switch message { + case let .raw(string): + return string + case let .mistralAI(mistralAIErrorMessage): + return mistralAIErrorMessage.message + ?? mistralAIErrorMessage.msg + ?? mistralAIErrorMessage.detail?.first?.msg + ?? "Unknown Error" + } + } + + enum CodingKeys: String, CodingKey { + case error + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + error = try container.decode(ErrorDetail.self, forKey: .error) + message = { + if let e = try? container.decode(MistralAIErrorMessage.self, forKey: .message) { + return CompletionAPIError.Message.mistralAI(e) + } + if let e = try? container.decode(String.self, forKey: .message) { + return .raw(e) + } + return .raw("Unknown Error") + }() + } + } + + var apiKey: String + var endpoint: URL + var requestBody: [String: Any] + var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? + + public init( + apiKey: String, + model: ChatModel, + endpoint: URL, + requestBody: Data, + requestModifier: ((inout URLRequest) -> Void)? = nil + ) { + self.apiKey = apiKey + self.endpoint = endpoint + self.requestBody = ( + try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] + ) ?? [:] + self.requestBody["model"] = model.info.modelName + self.model = model + self.requestModifier = requestModifier + } + + public func callAsFunction() async throws + -> URLSession.AsyncBytes + { + requestBody["stream"] = true + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.httpBody = try JSONSerialization.data( + withJSONObject: requestBody, + options: [] + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + Self.setupAppInformation(&request) + await Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) + + let (result, response) = try await URLSession.shared.bytes(for: request) + guard let response = response as? HTTPURLResponse else { + throw ChatGPTServiceError.responseInvalid + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + guard let data = text.data(using: .utf8) + else { throw ChatGPTServiceError.responseInvalid } + if response.statusCode == 403 { + throw ChatGPTServiceError.unauthorized(text) + } + let decoder = JSONDecoder() + let error = try? decoder.decode(CompletionAPIError.self, from: data) + throw error ?? ChatGPTServiceError.otherError( + text + + "\n\nPlease check your model settings, some capabilities may not be supported by the model." + ) + } + + return result + } + + public func callAsFunction() async throws -> Data { + let stream: URLSession.AsyncBytes = try await callAsFunction() + + return try await stream.reduce(into: Data()) { partialResult, byte in + partialResult.append(byte) + } + } + + static func setupAppInformation(_ request: inout URLRequest) { + if #available(macOS 13.0, *) { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } else { + if request.url?.host == "openrouter.ai" { + request.setValue("Copilot for Xcode", forHTTPHeaderField: "X-Title") + request.setValue( + "https://github.com/intitni/CopilotForXcode", + forHTTPHeaderField: "HTTP-Referer" + ) + } + } + } + + static func setupAPIKey(_ request: inout URLRequest, model: ChatModel, apiKey: String) async { + if !apiKey.isEmpty { + switch model.format { + case .openAI: + if !model.info.openAIInfo.organizationID.isEmpty { + request.setValue( + model.info.openAIInfo.organizationID, + forHTTPHeaderField: "OpenAI-Organization" + ) + } + + if !model.info.openAIInfo.projectID.isEmpty { + request.setValue( + model.info.openAIInfo.projectID, + forHTTPHeaderField: "OpenAI-Project" + ) + } + + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .openAICompatible: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .azureOpenAI: + request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break + case .googleAI: + assertionFailure("Unsupported") + case .ollama: + assertionFailure("Unsupported") + case .claude: + assertionFailure("Unsupported") + } + } + + if model.format == .gitHubCopilot, + let token = try? await GitHubCopilotExtension.fetchToken() + { + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + } + } + + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } + + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + let value = await parser.parse( + field.value, + context: .init(modelName: model.info.modelName, apiKey: apiKey) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index d5842b83..a1deaed5 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -58,6 +58,10 @@ extension TokenEncoder { } return await group.reduce(0, +) }) + for image in message.images { + encodingContent.append(image.urlString) + total += Int(Double(image.urlString.count) * 1.1) + } return total } diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index ca243511..38d6e26d 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -23,6 +23,7 @@ public struct EditorInformation: Sendable { public var lineAnnotations: [LineAnnotation] public var selectedContent: String { + guard !lines.isEmpty else { return "" } if let range = selections.first { if range.isEmpty { return "" } let startIndex = min( @@ -104,8 +105,12 @@ public struct EditorInformation: Sendable { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { + if range.start == range.end { + // Empty selection (cursor only): return empty code but include the containing line + return ("", lines(in: code, containing: range)) + } guard range.start < range.end else { return ("", []) } - + let rangeLines = lines(in: code, containing: range) if ignoreColumns { return (rangeLines.joined(), rangeLines) diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift index 6d549616..7ed68789 100644 --- a/Tool/Sources/Terminal/Terminal.swift +++ b/Tool/Sources/Terminal/Terminal.swift @@ -9,6 +9,13 @@ public protocol TerminalType { environment: [String: String] ) -> AsyncThrowingStream + func streamLineForCommand( + _ command: String, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String] + ) -> AsyncThrowingStream + func runCommand( _ command: String, arguments: [String], @@ -133,6 +140,46 @@ public final class Terminal: TerminalType, @unchecked Sendable { return contentStream } + public func streamLineForCommand( + _ command: String = "/bin/bash", + arguments: [String], + currentDirectoryURL: URL? = nil, + environment: [String: String] + ) -> AsyncThrowingStream { + let chunkStream = streamCommand( + command, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment + ) + + return AsyncThrowingStream { continuation in + Task { + var buffer = "" + do { + for try await chunk in chunkStream { + buffer.append(chunk) + + while let range = buffer.range(of: "\n") { + let line = String(buffer[..] = [:] + public init(messages: [Message]) { self.messages = messages } @@ -92,30 +95,54 @@ public class ToastController: ObservableObject { buttons: [Message.MessageButton] = [], duration: TimeInterval = 4 ) { - let id = UUID() - let message = Message( - id: id, - type: type, - namespace: namespace, - content: Text(content), - buttons: buttons.map { b in - Message.MessageButton(label: b.label, action: { [weak self] in - b.action() + Task { @MainActor in + // Find existing message with same content and type (and namespace) + if let existingIndex = messages.firstIndex(where: { + $0.type == type && + $0.content == Text(content) && + $0.namespace == namespace + }) { + let existingMessage = messages[existingIndex] + // Cancel previous removal task + removalTasks[existingMessage.id]?.cancel() + // Start new removal task for this message + removalTasks[existingMessage.id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) withAnimation(.easeInOut(duration: 0.2)) { - self?.messages.removeAll { $0.id == id } + messages.removeAll { $0.id == existingMessage.id } } - }) + removalTasks.removeValue(forKey: existingMessage.id) + } + return } - ) - - Task { @MainActor in + + let id = UUID() + let message = Message( + id: id, + type: type, + namespace: namespace, + content: Text(content), + buttons: buttons.map { b in + Message.MessageButton(label: b.label, action: { [weak self] in + b.action() + withAnimation(.easeInOut(duration: 0.2)) { + self?.messages.removeAll { $0.id == id } + } + }) + } + ) + withAnimation(.easeInOut(duration: 0.2)) { messages.append(message) messages = messages.suffix(3) } - try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) - withAnimation(.easeInOut(duration: 0.2)) { - messages.removeAll { $0.id == id } + + removalTasks[id] = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.2)) { + messages.removeAll { $0.id == id } + } + removalTasks.removeValue(forKey: id) } } } @@ -177,4 +204,3 @@ public struct Toast { } } } - diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index a70c0e2f..33291631 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -245,7 +245,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector, @unchecked S } } } else { - let window = XcodeWindowInspector(uiElement: window) + let window = XcodeWindowInspector(app: runningApplication, uiElement: window) focusedWindow = window } } else { @@ -391,6 +391,22 @@ extension XcodeAppInstanceInspector { let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) self.workspaces = workspaces } + + public func workspaceWindow( + forWorkspaceURL url: URL + ) -> AXUIElement? { + let windows = appElement.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + for window in windows { + if let workspaceURL = WorkspaceXcodeWindowInspector + .extractWorkspaceURL(windowElement: window), + workspaceURL == url + { + return window + } + } + return nil + } /// Use the project path as the workspace identifier. nonisolated static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { @@ -425,7 +441,7 @@ extension XcodeAppInstanceInspector { allTabs.insert(element.title) return .skipDescendants } - return .continueSearching + return .continueSearching(()) } } return allTabs @@ -480,14 +496,31 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool { } public extension AXUIElement { + var editorArea: AXUIElement? { + if description == "editor area" { return self } + var area: AXUIElement? + traverse { element, level in + if level > 10 { + return .skipDescendants + } + if element.description == "editor area" { + area = element + return .stopSearching + } + if element.description == "navigator" { + return .skipDescendants + } + + return .continueSearching(()) + } + return area + } + var tabBars: [AXUIElement] { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return [] } + guard let editorArea else { return [] } var tabBars = [AXUIElement]() - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { element.traverse { element, _ in @@ -495,7 +528,7 @@ public extension AXUIElement { tabBars.append(element) return .stopSearching } - return .continueSearching + return .continueSearching(()) } return .skipDescendantsAndSiblings @@ -521,20 +554,17 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return tabBars } var debugArea: AXUIElement? { - guard let editArea: AXUIElement = { - if description == "editor area" { return self } - return firstChild(where: { $0.description == "editor area" }) - }() else { return nil } + guard let editorArea else { return nil } var debugArea: AXUIElement? - editArea.traverse { element, _ in + editorArea.traverse { element, _ in let description = element.description if description == "Tab Bar" { return .skipDescendants @@ -561,7 +591,7 @@ public extension AXUIElement { return .skipDescendants } - return .continueSearching + return .continueSearching(()) } return debugArea diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d06c69c8..c26b7c7b 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -154,6 +154,9 @@ public final class XcodeInspector: Sendable { NotificationCenter.default.post(name: .focusedEditorDidChange, object: nil) } } + + @MainActor + public fileprivate(set) var latestFocusedEditor: SourceEditor? @MainActor public fileprivate(set) var focusedElement: AXUIElement? { @@ -172,7 +175,7 @@ public final class XcodeInspector: Sendable { let projectURL = realtimeActiveProjectURL else { return nil } - let editorContent = await focusedEditor?.getContent() + let editorContent = await latestFocusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") @@ -234,6 +237,7 @@ public final class XcodeInspector: Sendable { latestActiveXcode = nil activeApplication = nil focusedEditor = nil + latestFocusedEditor = nil focusedElement = nil } } @@ -389,6 +393,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else if let element = self.focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { @@ -396,6 +401,7 @@ public final class XcodeInspector: Sendable { runningApplication: xcode.runningApplication, element: editorElement ) + self.latestFocusedEditor = self.focusedEditor } else { self.focusedEditor = nil } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index f23087cc..c63b3f71 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -2,23 +2,47 @@ import AppKit import AsyncPassthroughSubject import AXExtension import Combine +import CoreGraphics import Foundation import Logger import Perception public class XcodeWindowInspector { - public let uiElement: AXUIElement + public let app: NSRunningApplication + public let windowID: CGWindowID + public var uiElement: AXUIElement { + let windowID = self.windowID + if _uiElement.parent == nil { + let app = AXUIElementCreateApplication(app.processIdentifier) + app.setMessagingTimeout(2) + if let newWindowElement = app.windows.first(where: { $0.windowID == windowID }) { + self._uiElement = newWindowElement + newWindowElement.setMessagingTimeout(2) + } + } + return _uiElement + } - init(uiElement: AXUIElement) { - self.uiElement = uiElement + var _uiElement: AXUIElement + + init( + app: NSRunningApplication, + uiElement: AXUIElement + ) { + self.app = app + _uiElement = uiElement uiElement.setMessagingTimeout(2) + windowID = uiElement.windowID ?? 0 + } + + public var isInvalid: Bool { + uiElement.parent == nil } } @XcodeInspectorActor @Perceptible public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable { - let app: NSRunningApplication @MainActor public private(set) var documentURL: URL = .init(fileURLWithPath: "/") @MainActor @@ -37,8 +61,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector, Sendable uiElement: AXUIElement, axNotifications: AsyncPassthroughSubject ) { - self.app = app - super.init(uiElement: uiElement) + super.init(app: app, uiElement: uiElement) focusedElementChangedTask = Task { @MainActor [weak self, axNotifications] in self?.updateURLs() diff --git a/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift new file mode 100644 index 00000000..1597c553 --- /dev/null +++ b/Tool/Tests/ModificationBasicTests/ExplanationThenCodeStreamParserTests.swift @@ -0,0 +1,289 @@ +import Foundation +import XCTest +@testable import ModificationBasic + +class ExplanationThenCodeStreamParserTests: XCTestCase { + func collectFragments(_ fragments: [ExplanationThenCodeStreamParser.Fragment]) -> ( + code: String, + explanation: String + ) { + var code = "" + var explanation = "" + for fragment in fragments { + switch fragment { + case let .code(c): + code += c + case let .explanation(e): + explanation += e + } + } + return (code: code, explanation: explanation) + } + + func process(_ code: String) async -> (code: String, explanation: String) { + let parser = ExplanationThenCodeStreamParser() + var allFragments: [ExplanationThenCodeStreamParser.Fragment] = [] + + func chunks(from code: String, chunkSize: Int) -> [String] { + var chunks: [String] = [] + var currentIndex = code.startIndex + + while currentIndex < code.endIndex { + let endIndex = code.index( + currentIndex, + offsetBy: chunkSize, + limitedBy: code.endIndex + ) ?? code.endIndex + let chunk = String(code[currentIndex..