diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift index cc2f4ab84..b78d00a42 100644 --- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -252,10 +252,19 @@ struct ChatSettingsGeneralSectionView: View { Text("7 Messages").tag(7) Text("9 Messages").tag(9) Text("11 Messages").tag(11) + Text("21 Messages").tag(21) + Text("31 Messages").tag(31) + Text("41 Messages").tag(41) + Text("51 Messages").tag(51) + Text("71 Messages").tag(71) + Text("91 Messages").tag(91) + Text("111 Messages").tag(111) + Text("151 Messages").tag(151) + Text("201 Messages").tag(201) } VStack(alignment: .leading, spacing: 4) { - Text("Default system prompt") + Text("Additional system prompt") EditableText(text: $settings.defaultChatSystemPrompt) .lineLimit(6) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 637b92ea4..f432f1c4b 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -84,6 +84,7 @@ public struct PromptToCodePanel { case cancelButtonTapped case acceptButtonTapped case acceptAndContinueButtonTapped + case statusUpdated([String]) case snippetPanel(IdentifiedActionOf) } @@ -128,7 +129,9 @@ public struct PromptToCodePanel { return .run { send in do { - let context = await contextInputController.resolveContext() + let context = await contextInputController.resolveContext(onStatusChange: { + await send(.statusUpdated($0)) + }) let agentFactory = context.agent ?? { SimpleModificationAgent() } _ = try await withThrowingTaskGroup(of: Void.self) { group in for (index, snippet) in snippets.enumerated() { @@ -216,11 +219,13 @@ public struct PromptToCodePanel { case .stopRespondingButtonTapped: state.promptToCodeState.isGenerating = false + state.promptToCodeState.status = [] return .cancel(id: CancellationKey.modifyCode(state.id)) case .modifyCodeFinished: state.contextInputController.instruction = .init("") state.promptToCodeState.isGenerating = false + state.promptToCodeState.status = [] if state.promptToCodeState.snippets.allSatisfy({ snippet in snippet.modifiedCode.isEmpty && snippet.description.isEmpty && snippet @@ -252,6 +257,10 @@ public struct PromptToCodePanel { await commandHandler.acceptModification() activateThisApp() } + + case let .statusUpdated(status): + state.promptToCodeState.status = status + return .none } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index 95510cfb0..97605a067 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -23,14 +23,17 @@ struct PromptToCodePanelView: View { TopBar(store: store) Content(store: store) - .overlay(alignment: .bottom) { - ActionBar(store: store) - } .safeAreaInset(edge: .bottom) { - if let inputField = customizedViews.contextInputField { - inputField - } else { - Toolbar(store: store) + VStack { + StatusBar(store: store) + + ActionBar(store: store) + + if let inputField = customizedViews.contextInputField { + inputField + } else { + Toolbar(store: store) + } } } } @@ -143,6 +146,70 @@ extension PromptToCodePanelView { } } + struct StatusBar: View { + let store: StoreOf + @State var isAllStatusesPresented = false + var body: some View { + WithPerceptionTracking { + if store.promptToCodeState.isGenerating, !store.promptToCodeState.status.isEmpty { + if let firstStatus = store.promptToCodeState.status.first { + let count = store.promptToCodeState.status.count + Button(action: { + isAllStatusesPresented = true + }) { + HStack { + Text(firstStatus) + .lineLimit(1) + .font(.caption) + + Text("\(count)") + .lineLimit(1) + .font(.caption) + .background( + Circle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 12, height: 12) + ) + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: 400) + .popover(isPresented: $isAllStatusesPresented, arrowEdge: .top) { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 16) { + ForEach(store.promptToCodeState.status, id: \.self) { status in + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .controlSize(.small) + Text(status) + } + } + } + .padding() + } + } + .onChange(of: store.promptToCodeState.isGenerating) { isGenerating in + if !isGenerating { + isAllStatusesPresented = false + } + } + } + } + } + } + } + struct ActionBar: View { let store: StoreOf @@ -433,7 +500,7 @@ extension PromptToCodePanelView { } } } - + Spacer(minLength: 56) } } @@ -575,7 +642,7 @@ extension PromptToCodePanelView { presentAllContent: !isGenerating ) } else { - ScrollView(.horizontal) { + MinScrollView { CodeBlockInContent( store: store, language: language, @@ -607,6 +674,37 @@ extension PromptToCodePanelView { } } + struct MinWidthPreferenceKey: PreferenceKey { + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } + + static var defaultValue: CGFloat = 0 + } + + struct MinScrollView: View { + @ViewBuilder let content: Content + @State var minWidth: CGFloat = 0 + + var body: some View { + ScrollView(.horizontal) { + content + .frame(minWidth: minWidth) + } + .overlay { + GeometryReader { proxy in + Color.clear.preference( + key: MinWidthPreferenceKey.self, + value: proxy.size.width + ) + } + } + .onPreferenceChange(MinWidthPreferenceKey.self) { + minWidth = $0 + } + } + } + struct CodeBlockInContent: View { let store: StoreOf let language: CodeLanguage @@ -857,3 +955,59 @@ extension PromptToCodePanelView { .frame(width: 500, height: 500, alignment: .center) } +#Preview("Generating") { + PromptToCodePanelView(store: .init(initialState: .init( + promptToCodeState: Shared(ModificationState( + source: .init( + language: CodeLanguage.builtIn(.swift), + documentURL: URL( + fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt" + ), + projectRootURL: URL(fileURLWithPath: "path/to/file.txt"), + content: "", + lines: [] + ), + snippets: [ + .init( + startLineIndex: 8, + originalCode: "print(foo)", + modifiedCode: "print(bar)", + description: "", + error: "Error", + attachedRange: CursorRange( + start: .init(line: 8, character: 0), + end: .init(line: 12, character: 2) + ) + ), + .init( + startLineIndex: 13, + originalCode: """ + struct Bar { + var foo: Int + } + """, + modifiedCode: """ + struct Bar { + var foo: String + } + """, + description: "Cool", + error: nil, + attachedRange: CursorRange( + start: .init(line: 13, character: 0), + end: .init(line: 12, character: 2) + ) + ), + ], + extraSystemPrompt: "", + isAttachedToTarget: true, + isGenerating: true, + status: ["Status 1", "Status 2"] + )), + instruction: nil, + commandName: "Generate Code" + ), reducer: { PromptToCodePanel() })) + .frame(maxWidth: 450, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 500, height: 500, alignment: .center) +} diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index ce2e44dd4..e0e25de4a 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -789,7 +789,7 @@ public final class WidgetWindows { @MainActor lazy var toastWindow = { let it = WidgetWindow( - contentRect: .zero, + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), styleMask: [.borderless], backing: .buffered, defer: false diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 1d107672d..801ce37f0 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -1,6 +1,7 @@ import FileChangeChecker import LaunchAgentManager import Logger +import Perception import Preferences import Service import ServiceManagement @@ -29,6 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { ) func applicationDidFinishLaunching(_: Notification) { +// isPerceptionCheckingEnabled = false if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } _ = XcodeInspector.shared updateChecker.updateCheckerDelegate = self diff --git a/Screenshot.png b/Screenshot.png index 9b2bda3a0..b4ad1f43d 100644 Binary files a/Screenshot.png and b/Screenshot.png differ diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index 24e9a793e..e83914432 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -94,6 +94,10 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var maxTokens: Int @FallbackDecoding public var supportsFunctionCalling: Bool + @FallbackDecoding + public var supportsImage: Bool + @FallbackDecoding + public var supportsAudio: Bool @FallbackDecoding public var modelName: String @@ -114,6 +118,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { isFullURL: Bool = false, maxTokens: Int = 4000, supportsFunctionCalling: Bool = true, + supportsImage: Bool = false, + supportsAudio: Bool = false, modelName: String = "", openAIInfo: OpenAIInfo = OpenAIInfo(), ollamaInfo: OllamaInfo = OllamaInfo(), @@ -126,6 +132,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { self.isFullURL = isFullURL self.maxTokens = maxTokens self.supportsFunctionCalling = supportsFunctionCalling + self.supportsImage = supportsImage + self.supportsAudio = supportsAudio self.modelName = modelName self.openAIInfo = openAIInfo self.ollamaInfo = ollamaInfo diff --git a/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/LanguageServer/CodeiumInstallationManager.swift index 87e5a6efd..13e430e67 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.20.9" + static let latestSupportedVersion = "1.28.3" static let minimumSupportedVersion = "1.20.0" public init() {} @@ -90,11 +90,23 @@ public struct CodeiumInstallationManager { case .orderedAscending: switch version.compare(Self.minimumSupportedVersion) { case .orderedAscending: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: true) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: true + ) case .orderedSame: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) case .orderedDescending: - return .outdated(current: version, latest: Self.latestSupportedVersion, mandatory: false) + return .outdated( + current: version, + latest: Self.latestSupportedVersion, + mandatory: false + ) } case .orderedSame: return .installed(version) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift index 64b358688..3f79212e9 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -14,6 +14,7 @@ class CopilotLocalProcessServer { private var wrappedServer: CustomJSONRPCLanguageServer? var terminationHandler: (() -> Void)? @MainActor var ongoingCompletionRequestIDs: [JSONId] = [] + @MainActor var ongoingConversationRequestIDs: [String: JSONId] = [:] public convenience init( path: String, @@ -58,6 +59,21 @@ class CopilotLocalProcessServer { Task { @MainActor [weak self] in self?.ongoingCompletionRequestIDs.append(request.id) } + } else if request.method == "conversation/create" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode( + GitHubCopilotRequest.ConversationCreate.RequestBody.self, + from: paramsData + ) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + print("Error decoding ConversationCreateParams: \(error)") + } + } + } } } @@ -131,12 +147,7 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { let task = Task { @MainActor in for id in self.ongoingCompletionRequestIDs { - switch id { - case let .numericId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - case let .stringId(id): - try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) - } + await cancelTask(id) } self.ongoingCompletionRequestIDs = [] } @@ -144,6 +155,27 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server { await task.value } + public func cancelOngoingTask(workDoneToken: String) async { + let task = Task { @MainActor in + guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } + await cancelTask(id) + } + await task.value + } + + public func cancelTask(_ id: JSONId) async { + guard let server = wrappedServer, process.isRunning else { + return + } + + switch id { + case let .numericId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + case let .stringId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + } + } + public func sendRequest( _ request: ClientRequest, completionHandler: @escaping (ServerResult) -> Void diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift index ed11d4b21..640a09a34 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift @@ -6,12 +6,12 @@ public struct GitHubCopilotInstallationManager { public private(set) static var isInstalling = false static var downloadURL: URL { - let commitHash = "782461159655b259cff10ecff05efa761e3d4764" + let commitHash = "87038123804796ca7af20d1b71c3428d858a9124" let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip" return URL(string: link)! } - static let latestSupportedVersion = "1.40.0" + static let latestSupportedVersion = "1.41.0" static let minimumSupportedVersion = "1.32.0" public init() {} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index 1b80058c0..1cbbf5468 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -2,6 +2,7 @@ import Foundation import JSONRPC import LanguageServerProtocol import SuggestionBasic +import XcodeInspector struct GitHubCopilotDoc: Codable { var source: String @@ -55,6 +56,8 @@ enum GitHubCopilotChatSource: String, Codable { enum GitHubCopilotRequest { struct SetEditorInfo: GitHubCopilotRequestType { + let xcodeVersion: String + struct Response: Codable {} var networkProxy: JSONValue? { @@ -142,12 +145,13 @@ enum GitHubCopilotRequest { "name": "vscode", "version": "1.89.1", ]) : .hash([ - "name": "xcode", - "version": "", + "name": "Xcode", + "version": .string(xcodeVersion), ]), "editorPluginInfo": .hash([ "name": "Copilot for Xcode", - "version": "", + "version": .string(Bundle.main + .infoDictionary?["CFBundleShortVersionString"] as? String ?? ""), ]), ] diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 9a4b83e40..bf7d24e4f 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -6,6 +6,7 @@ import LanguageServerProtocol import Logger import Preferences import SuggestionBasic +import XcodeInspector public protocol GitHubCopilotAuthServiceType { func checkStatus() async throws -> GitHubCopilotAccountStatus @@ -34,6 +35,7 @@ public protocol GitHubCopilotSuggestionServiceType { func notifySaveTextDocument(fileURL: URL) async throws func cancelRequest() async func terminate() async + func cancelOngoingTask(workDoneToken: String) async } protocol GitHubCopilotLSP { @@ -165,6 +167,16 @@ public class GitHubCopilotBaseService { } }() + #if DEBUG + let environment: [String: String] = [ + "GH_COPILOT_DEBUG_UI_PORT": "8080", + "GH_COPILOT_VERBOSE": UserDefaults.shared.value(for: \.gitHubCopilotVerboseLog) + ? "true" : "false", + ] + #else + let environment = [String: String]() + #endif + switch runner { case .bash: let nodePath = UserDefaults.shared.value(for: \.nodePath) @@ -176,7 +188,7 @@ public class GitHubCopilotBaseService { executionParams = Process.ExecutionParameters( path: "/bin/bash", arguments: ["-i", "-l", "-c", command], - environment: [:], + environment: environment, currentDirectoryURL: urls.supportURL ) case .shell: @@ -190,7 +202,7 @@ public class GitHubCopilotBaseService { executionParams = Process.ExecutionParameters( path: shell, arguments: ["-i", "-l", "-c", command], - environment: [:], + environment: environment, currentDirectoryURL: urls.supportURL ) case .env: @@ -242,7 +254,10 @@ public class GitHubCopilotBaseService { initializationOptions: nil, capabilities: capabilities, trace: .off, - workspaceFolders: nil + workspaceFolders: [WorkspaceFolder( + uri: projectRootURL.path, + name: projectRootURL.lastPathComponent + )] ) } @@ -255,11 +270,15 @@ public class GitHubCopilotBaseService { let notifications = NotificationCenter.default .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) Task { [weak self] in - _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) for await _ in notifications { guard self != nil else { return } - _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + _ = try? await server.sendRequest( + GitHubCopilotRequest.SetEditorInfo(xcodeVersion: xcodeVersion() ?? "16.0") + ) } } } @@ -587,6 +606,10 @@ public final class GitHubCopilotService: GitHubCopilotBaseService, public func terminate() async { // automatically handled } + + public func cancelOngoingTask(workDoneToken: String) async { + await localProcessServer?.cancelOngoingTask(workDoneToken: workDoneToken) + } } extension InitializingServer: GitHubCopilotLSP { @@ -606,3 +629,30 @@ extension InitializingServer: GitHubCopilotLSP { } } +private func xcodeVersion() async -> String? { + if let xcode = await XcodeInspector.shared.safe.latestActiveXcode { + return xcode.version + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["xcodebuild", "-version"] + + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + } catch { + print("Error running xcrun xcodebuild: \(error)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + + let lines = output.split(separator: "\n") + return lines.first?.split(separator: " ").last.map(String.init) +} + diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift index 9f5f3984a..459b259ac 100644 --- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotChatService.swift @@ -50,7 +50,10 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { let startTimestamp = Date() continuation.onTermination = { _ in - Task { service.unregisterNotificationHandler(id: id) } + Task { + service.unregisterNotificationHandler(id: id) + await service.cancelOngoingTask(workDoneToken: workDoneToken) + } } service.registerNotificationHandler(id: id) { notification, data in @@ -71,12 +74,20 @@ public final class GitHubCopilotChatService: BuiltinExtensionChatServiceType { if let reply = progress.value.reply, progress.value.kind == "report" { continuation.yield(reply) } else if progress.value.kind == "end" { - if let error = progress.value.error, + if let error = progress.value.error?.message, progress.value.cancellationReason == nil { - continuation.finish( - throwing: GitHubCopilotError.chatEndsWithError(error) - ) + if error.contains("400") { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError( + "\(error). Please try enabling pretend IDE to be VSCode and click refresh configuration." + ) + ) + } else { + continuation.finish( + throwing: GitHubCopilotError.chatEndsWithError(error) + ) + } } else { continuation.finish() } @@ -198,6 +209,11 @@ extension GitHubCopilotChatService { var message: String } + struct Error: Decodable { + var responseIsIncomplete: Bool? + var message: String? + } + var kind: String var title: String? var conversationId: String @@ -209,7 +225,7 @@ extension GitHubCopilotChatService { var annotations: [String]? var hideText: Bool? var cancellationReason: String? - var error: String? + var error: Error? } var token: String diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 2e34b3cea..390e54d5c 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -23,7 +23,6 @@ public final class Logger { public static let license = Logger(category: "License") public static let `extension` = Logger(category: "Extension") public static let communicationBridge = Logger(category: "CommunicationBridge") - public static let resolver = Logger(category: "Resolver") public static let debug = Logger(category: "Debug") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. diff --git a/Tool/Sources/ModificationBasic/ModificationState.swift b/Tool/Sources/ModificationBasic/ModificationState.swift index c60f338d4..40bbe8f0b 100644 --- a/Tool/Sources/ModificationBasic/ModificationState.swift +++ b/Tool/Sources/ModificationBasic/ModificationState.swift @@ -11,20 +11,24 @@ public struct ModificationState { public var isGenerating: Bool = false public var extraSystemPrompt: String public var isAttachedToTarget: Bool = true + public var status = [String]() public init( source: Source, history: [ModificationHistoryNode] = [], snippets: IdentifiedArrayOf, extraSystemPrompt: String, - isAttachedToTarget: Bool + isAttachedToTarget: Bool, + isGenerating: Bool = false, + status: [String] = [] ) { self.history = history self.snippets = snippets - isGenerating = false + self.isGenerating = isGenerating self.isAttachedToTarget = isAttachedToTarget self.extraSystemPrompt = extraSystemPrompt self.source = source + self.status = status } public init( diff --git a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift index 50f4d3e30..c08435ae5 100644 --- a/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/BuiltinExtensionChatCompletionsService.swift @@ -115,7 +115,11 @@ extension BuiltinExtensionChatCompletionsService { .joined(separator: "\n\n") let history = Array(messages[0...lastIndexNotUserMessage]) return (message, history.map { - .init(id: UUID().uuidString, role: $0.role.asChatMessageRole, content: $0.content) + .init( + id: UUID().uuidString, + role: $0.role.asChatMessageRole, + content: $0.content + ) }) } else { // everything is user message let message = messages.map { $0.content }.joined(separator: "\n\n") diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index 786e49817..f8f1a5ffa 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -1,17 +1,17 @@ import AIModel +import ChatBasic import CodableWrappers import Foundation import Preferences -import ChatBasic -struct ChatCompletionsRequestBody: Codable, Equatable { - struct Message: Codable, Equatable { - enum Role: String, Codable, Equatable { +struct ChatCompletionsRequestBody: Equatable { + struct Message: Equatable { + enum Role: String, Equatable { case system case user case assistant case tool - + var asChatMessageRole: ChatMessage.Role { switch self { case .system: @@ -25,6 +25,31 @@ struct ChatCompletionsRequestBody: Codable, Equatable { } } } + + struct Image: Equatable { + enum Format: String { + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + } + var data: Data + var format: Format + + var dataURLString: String { + let base64 = data.base64EncodedString() + return "data:\(format.rawValue);base64,\(base64)" + } + } + + struct Audio: Equatable { + enum Format: String { + case wav + case mp3 + } + + var data: Data + var format: Format + } /// The role of the message. var role: Role @@ -34,25 +59,29 @@ struct ChatCompletionsRequestBody: Codable, Equatable { /// name of the function call, and include the result in `content`. /// /// - important: It's required when the role is `function`. - var name: String? + var name: String? = nil /// Tool calls in an assistant message. - var toolCalls: [MessageToolCall]? + var toolCalls: [MessageToolCall]? = nil /// When we want to call a tool, we have to provide the id of the call. /// /// - important: It's required when the role is `tool`. - var toolCallId: String? + var toolCallId: String? = nil + /// Images to include in the message. + var images: [Image] = [] + /// Audios to include in the message. + var audios: [Audio] = [] /// Cache the message if possible. var cacheIfPossible: Bool = false } - struct MessageFunctionCall: Codable, Equatable { + struct MessageFunctionCall: Equatable { /// The name of the var name: String /// A JSON string. var arguments: String? } - struct MessageToolCall: Codable, Equatable { + struct MessageToolCall: Equatable { /// The id of the tool call. var id: String /// The type of the tool. @@ -61,7 +90,7 @@ struct ChatCompletionsRequestBody: Codable, Equatable { var function: MessageFunctionCall } - struct Tool: Codable, Equatable { + struct Tool: Equatable { var type: String = "function" var function: ChatGPTFunctionSchema } @@ -182,11 +211,11 @@ struct ChatCompletionsStreamDataChunk { var content: String? var toolCalls: [ToolCall]? } - + struct Usage: Codable, Equatable { var promptTokens: Int? var completionTokens: Int? - + var cachedTokens: Int? var otherUsage: [String: Int] } @@ -205,16 +234,35 @@ protocol ChatCompletionsAPI { func callAsFunction() async throws -> ChatCompletionResponseBody } -struct ChatCompletionResponseBody: Codable, Equatable { - typealias Message = ChatCompletionsRequestBody.Message - - struct Usage: Codable, Equatable { +struct ChatCompletionResponseBody: Equatable { + struct Message: Equatable { + typealias Role = ChatCompletionsRequestBody.Message.Role + typealias MessageToolCall = ChatCompletionsRequestBody.MessageToolCall + + /// The role of the message. + var role: Role + /// The content of the message. + var content: String? + /// When we want to reply to a function call with the result, we have to provide the + /// name of the function call, and include the result in `content`. + /// + /// - important: It's required when the role is `function`. + var name: String? + /// Tool calls in an assistant message. + var toolCalls: [MessageToolCall]? + /// When we want to call a tool, we have to provide the id of the call. + /// + /// - important: It's required when the role is `tool`. + var toolCallId: String? + } + + struct Usage: Equatable { var promptTokens: Int var completionTokens: Int - + var cachedTokens: Int var otherUsage: [String: Int] - + mutating func merge(with other: ChatCompletionsStreamDataChunk.Usage) { promptTokens += other.promptTokens ?? 0 completionTokens += other.completionTokens ?? 0 @@ -223,7 +271,7 @@ struct ChatCompletionResponseBody: Codable, Equatable { otherUsage[key, default: 0] += value } } - + mutating func merge(with other: Self) { promptTokens += other.promptTokens completionTokens += other.completionTokens diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 4ed48635a..c57935b0b 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -385,9 +385,11 @@ extension ClaudeChatCompletionsService.StreamDataChunk { extension ClaudeChatCompletionsService.RequestBody { init(_ body: ChatCompletionsRequestBody) { model = body.model - let supportsPromptCache = if model.hasPrefix("claude-3-5-sonnet") || model - .hasPrefix("claude-3-opus") || model.hasPrefix("claude-3-haiku") - { + let prefixChecks = [ + "claude-3-5-sonnet", "claude-3-5-haiku", "claude-3-opus", "claude-3-haiku", + "claude-3.5-sonnet", "claude-3.5-haiku", + ] + let supportsPromptCache = if prefixChecks.contains(where: model.hasPrefix) { true } else { false @@ -432,6 +434,41 @@ extension ClaudeChatCompletionsService.RequestBody { return false } + func convertMessageContent( + _ message: ChatCompletionsRequestBody.Message + ) -> [MessageContent] { + var content = [MessageContent]() + + content.append(.init(type: .text, text: message.content, cache_control: { + if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() { + return .init() + } else { + return nil + } + }())) + for image in message.images { + content.append(.init(type: .image, source: .init( + type: "base64", + media_type: image.format.rawValue, + data: image.data.base64EncodedString() + ))) + } + + return content + } + + func convertMessage(_ message: ChatCompletionsRequestBody.Message) -> Message { + let role: ClaudeChatCompletionsService.MessageRole = switch message.role { + case .system: .assistant + case .assistant, .tool: .assistant + case .user: .user + } + + let content: [MessageContent] = convertMessageContent(message) + + return .init(role: role, content: content) + } + for message in body.messages { switch message.role { case .system: @@ -445,38 +482,18 @@ extension ClaudeChatCompletionsService.RequestBody { case .tool, .assistant: switch checkJoinType(for: message) { case .appendToList: - nonSystemMessages.append(.init( - role: .assistant, - content: [.init(type: .text, text: message.content)] - )) + nonSystemMessages.append(convertMessage(message)) case .padMessageAndAppendToList, .joinMessage: - nonSystemMessages[nonSystemMessages.endIndex - 1].content.append( - .init(type: .text, text: message.content, cache_control: { - if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() { - return .init() - } else { - return nil - } - }()) - ) + nonSystemMessages[nonSystemMessages.endIndex - 1].content + .append(contentsOf: convertMessageContent(message)) } case .user: switch checkJoinType(for: message) { case .appendToList: - nonSystemMessages.append(.init( - role: .user, - content: [.init(type: .text, text: message.content)] - )) + nonSystemMessages.append(convertMessage(message)) case .padMessageAndAppendToList, .joinMessage: - nonSystemMessages[nonSystemMessages.endIndex - 1].content.append( - .init(type: .text, text: message.content, cache_control: { - if message.cacheIfPossible, supportsPromptCache, consumeCacheControl() { - return .init() - } else { - return nil - } - }()) - ) + nonSystemMessages[nonSystemMessages.endIndex - 1].content + .append(contentsOf: convertMessageContent(message)) } } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 946840305..858f3149e 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -160,12 +160,73 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI var choices: [Choice] } - struct RequestBody: Codable, Equatable { - struct Message: Codable, Equatable { + struct RequestBody: Encodable, Equatable { + typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + + struct Message: Encodable, Equatable { + enum MessageContent: Encodable, Equatable { + struct TextContentPart: Encodable, Equatable { + var type = "text" + var text: String + var cache_control: ClaudeCacheControl? + } + + struct ImageContentPart: Encodable, Equatable { + struct ImageURL: Encodable, Equatable { + var url: String + var detail: String? + } + + var type = "image_url" + var image_url: ImageURL + } + + struct AudioContentPart: Encodable, Equatable { + struct InputAudio: Encodable, Equatable { + var data: String + var format: String + } + + var type = "input_audio" + var input_audio: InputAudio + } + + enum ContentPart: Encodable, Equatable { + case text(TextContentPart) + case image(ImageContentPart) + case audio(AudioContentPart) + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .text(text): + try container.encode(text) + case let .image(image): + try container.encode(image) + case let .audio(audio): + try container.encode(audio) + } + } + } + + case contentParts([ContentPart]) + case text(String) + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .contentParts(parts): + try container.encode(parts) + case let .text(text): + try container.encode(text) + } + } + } + /// The role of the message. var role: MessageRole /// The content of the message. - var content: String + var content: MessageContent /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// @@ -201,12 +262,12 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI var function: MessageFunctionCall? } - struct Tool: Codable, Equatable { + struct Tool: Encodable, Equatable { var type: String = "function" var function: ChatGPTFunctionSchema } - struct StreamOptions: Codable, Equatable { + struct StreamOptions: Encodable, Equatable { var include_usage: Bool = true } @@ -236,8 +297,11 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI self.endpoint = endpoint self.requestBody = .init( requestBody, + endpoint: endpoint, enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, - canUseTool: model.info.supportsFunctionCalling + canUseTool: model.info.supportsFunctionCalling, + supportsImage: model.info.supportsImage, + supportsAudio: model.info.supportsAudio ) self.model = model } @@ -334,7 +398,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI body.message.role = role } if let text = chunk.message?.content { - body.message.content += text + let existed = body.message.content ?? "" + body.message.content = existed + text } if let usage = chunk.usage { body.usage?.merge(with: usage) @@ -395,7 +460,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } } - + static func setupExtraHeaderFields(_ request: inout URLRequest, model: ChatModel) { for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { request.setValue(field.value, forHTTPHeaderField: field.key) @@ -449,7 +514,7 @@ extension OpenAIChatCompletionsService.ResponseBody { message = .init(role: .assistant, content: "") otherMessages = [] } - + let usage = ChatCompletionResponseBody.Usage( promptTokens: usage.prompt_tokens ?? 0, completionTokens: usage.completion_tokens ?? 0, @@ -539,24 +604,164 @@ extension OpenAIChatCompletionsService.StreamDataChunk { } extension OpenAIChatCompletionsService.RequestBody { - init(_ body: ChatCompletionsRequestBody, enforceMessageOrder: Bool, canUseTool: Bool) { + static func convertContentPart( + content: String, + images: [ChatCompletionsRequestBody.Message.Image], + audios: [ChatCompletionsRequestBody.Message.Audio] + ) -> [Message.MessageContent.ContentPart] { + var all = [Message.MessageContent.ContentPart]() + all.append(.text(.init(text: content))) + + for image in images { + all.append(.image(.init( + image_url: .init( + url: image.dataURLString, + detail: nil + ) + ))) + } + + for audio in audios { + all.append(.audio(.init( + input_audio: .init( + data: audio.data.base64EncodedString(), + format: audio.format.rawValue + ) + ))) + } + + return all + } + + static func convertContentPart( + _ part: ClaudeChatCompletionsService.RequestBody.MessageContent + ) -> Message.MessageContent.ContentPart? { + switch part.type { + case .text: + return .text(.init(text: part.text ?? "", cache_control: part.cache_control)) + case .image: + let type = part.source?.type ?? "base64" + let base64Data = part.source?.data ?? "" + let mediaType = part.source?.media_type ?? "image/png" + return .image(.init(image_url: .init(url: "data:\(mediaType);\(type),\(base64Data)"))) + } + } + + static func joinMessageContent( + _ message: inout Message, + content: String, + images: [ChatCompletionsRequestBody.Message.Image], + audios: [ChatCompletionsRequestBody.Message.Audio] + ) { + switch message.role { + case .system, .assistant, .user: + let newParts = Self.convertContentPart( + content: content, + images: images, + audios: audios + ) + if case let .contentParts(existingParts) = message.content { + message.content = .contentParts(existingParts + newParts) + } else { + message.content = .contentParts(newParts) + } + case .tool, .function: + if case let .text(existingText) = message.content { + message.content = .text(existingText + "\n\n" + content) + } else { + message.content = .text(content) + } + } + } + + init( + _ body: ChatCompletionsRequestBody, + endpoint: URL, + enforceMessageOrder: Bool, + canUseTool: Bool, + supportsImage: Bool, + supportsAudio: Bool + ) { + temperature = body.temperature + stream = body.stream + stop = body.stop + max_completion_tokens = body.maxTokens + tool_choice = body.toolChoice + tools = body.tools?.map { + Tool( + type: $0.type, + function: $0.function + ) + } + stream_options = if body.stream ?? false { + StreamOptions() + } else { + nil + } + model = body.model + + // Special case for Claude through OpenRouter + + if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") { + var body = body + body.model = model.replacingOccurrences(of: "anthropic/", with: "") + let claudeRequestBody = ClaudeChatCompletionsService.RequestBody(body) + messages = claudeRequestBody.system.map { + Message( + role: .system, + content: .contentParts([.text(.init( + text: $0.text, + cache_control: $0.cache_control + ))]) + ) + } + claudeRequestBody.messages.map { + (message: ClaudeChatCompletionsService.RequestBody.Message) in + let role: OpenAIChatCompletionsService.MessageRole = switch message.role { + case .user: .user + case .assistant: .assistant + } + return Message( + role: role, + content: .contentParts(message.content.compactMap(Self.convertContentPart)), + name: nil, + tool_calls: nil, + tool_call_id: nil + ) + } + return + } + + // Enforce message order + if enforceMessageOrder { - var systemPrompts = [String]() + var systemPrompts = [Message.MessageContent.ContentPart]() var nonSystemMessages = [Message]() for message in body.messages { switch (message.role, canUseTool) { case (.system, _): - systemPrompts.append(message.content) + systemPrompts.append(contentsOf: Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) case (.tool, true): if let last = nonSystemMessages.last, last.role == .tool { - nonSystemMessages[nonSystemMessages.endIndex - 1].content - += "\n\n\(message.content)" + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + ) } else { nonSystemMessages.append(.init( role: .tool, - content: message.content, + content: .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )), tool_calls: message.toolCalls?.map { tool in MessageToolCall( id: tool.id, @@ -571,19 +776,38 @@ extension OpenAIChatCompletionsService.RequestBody { } case (.assistant, _), (.tool, false): if let last = nonSystemMessages.last, last.role == .assistant { - nonSystemMessages[nonSystemMessages.endIndex - 1].content - += "\n\n\(message.content)" + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + ) } else { - nonSystemMessages.append(.init(role: .assistant, content: message.content)) + nonSystemMessages.append(.init( + role: .assistant, + content: .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )) + )) } case (.user, _): if let last = nonSystemMessages.last, last.role == .user { - nonSystemMessages[nonSystemMessages.endIndex - 1].content - += "\n\n\(message.content)" + Self.joinMessageContent( + &nonSystemMessages[nonSystemMessages.endIndex - 1], + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + ) } else { nonSystemMessages.append(.init( role: .user, - content: message.content, + content: .contentParts(Self.convertContentPart( + content: message.content, + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )), name: message.name, tool_call_id: message.toolCallId )) @@ -593,57 +817,48 @@ extension OpenAIChatCompletionsService.RequestBody { messages = [ .init( role: .system, - content: systemPrompts.joined(separator: "\n\n") - .trimmingCharacters(in: .whitespacesAndNewlines) + content: .contentParts(systemPrompts) ), ] + nonSystemMessages - } else { - messages = body.messages.map { message in - .init( - role: { - switch message.role { - case .user: - return .user - case .assistant: - return .assistant - case .system: - return .system - case .tool: - return .tool - } - }(), + + return + } + + // Default + + messages = body.messages.map { message in + .init( + role: { + switch message.role { + case .user: + return .user + case .assistant: + return .assistant + case .system: + return .system + case .tool: + return .tool + } + }(), + content: .contentParts(Self.convertContentPart( content: message.content, - name: message.name, - tool_calls: message.toolCalls?.map { tool in - MessageToolCall( - id: tool.id, - type: tool.type, - function: MessageFunctionCall( - name: tool.function.name, - arguments: tool.function.arguments - ) + images: supportsImage ? message.images : [], + audios: supportsAudio ? message.audios : [] + )), + name: message.name, + tool_calls: message.toolCalls?.map { tool in + MessageToolCall( + id: tool.id, + type: tool.type, + function: MessageFunctionCall( + name: tool.function.name, + arguments: tool.function.arguments ) - }, - tool_call_id: message.toolCallId - ) - } - } - temperature = body.temperature - stream = body.stream - stop = body.stop - max_completion_tokens = body.maxTokens - tool_choice = body.toolChoice - tools = body.tools?.map { - Tool( - type: $0.type, - function: $0.function + ) + }, + tool_call_id: message.toolCallId ) } - stream_options = if body.stream ?? false { - StreamOptions() - } else { - nil - } } } diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index e53c7c3c5..c10cb01c9 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -565,6 +565,8 @@ extension ChatGPTService { nil } }(), + images: [], + audios: [], cacheIfPossible: chatMessage.cacheIfPossible )) diff --git a/Tool/Sources/OpenAIService/Debug/Debug.swift b/Tool/Sources/OpenAIService/Debug/Debug.swift index 31864964b..37db7031b 100644 --- a/Tool/Sources/OpenAIService/Debug/Debug.swift +++ b/Tool/Sources/OpenAIService/Debug/Debug.swift @@ -7,20 +7,15 @@ enum Debugger { #if DEBUG static func didSendRequestBody(body: ChatCompletionsRequestBody) { - do { - let json = try JSONEncoder().encode(body) - let center = NotificationCenter.default - center.post( - name: .init("ServiceDebugger.ChatRequestDebug.requestSent"), - object: nil, - userInfo: [ - "id": id ?? UUID(), - "data": json, - ] - ) - } catch { - print("Failed to encode request body: \(error)") - } + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.requestSent"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "data": body, + ] + ) } static func didReceiveFunction(name: String, arguments: String) { diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift index 385f12646..da2205f76 100644 --- a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift @@ -44,8 +44,14 @@ public actor TemplateChatGPTMemory: ChatGPTMemory { return tokenCount <= configuration.maxTokens - configuration.minimumReplyTokens } + var truncationTimes = 500 while !(await checkTokenCount()) { do { + truncationTimes -= 1 + if truncationTimes <= 0 { + throw CancellationError() + } + try Task.checkCancellation() try await memoryTemplate.truncate() } catch { Logger.service.error("Failed to truncate prompt template: \(error)") @@ -180,6 +186,8 @@ public struct MemoryTemplate { } mutating func truncate() async throws { + if Task.isCancelled { return } + if let truncateRule = truncateRule { try await truncateRule(&messages, &followUpMessages) return diff --git a/Tool/Sources/Preferences/Types/StorableColors.swift b/Tool/Sources/Preferences/Types/StorableColors.swift index 61dcbb516..070092c04 100644 --- a/Tool/Sources/Preferences/Types/StorableColors.swift +++ b/Tool/Sources/Preferences/Types/StorableColors.swift @@ -18,7 +18,7 @@ public struct StorableColor: Codable, Equatable { import SwiftUI public extension StorableColor { var swiftUIColor: SwiftUI.Color { - SwiftUI.Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + SwiftUI.Color(nsColor: nsColor) } } #endif @@ -28,7 +28,7 @@ import AppKit public extension StorableColor { var nsColor: NSColor { NSColor( - srgbRed: CGFloat(red), + calibratedRed: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: CGFloat(alpha) diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift index 8faa61108..46eccad2f 100644 --- a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift +++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift @@ -55,7 +55,7 @@ public protocol PromptToCodeContextInputControllerDelegate { public protocol PromptToCodeContextInputController: Perception.Perceptible { var instruction: NSAttributedString { get set } - func resolveContext() async -> ( + func resolveContext(onStatusChange: @escaping ([String]) async -> Void) async -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], @@ -100,7 +100,7 @@ public final class DefaultPromptToCodeContextInputController: PromptToCodeContex instruction = mutable } - public func resolveContext() -> ( + public func resolveContext(onStatusChange: @escaping ([String]) async -> Void) -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift index e698eee63..f5be38075 100644 --- a/Tool/Sources/SharedUIComponents/CopyButton.swift +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -26,18 +26,19 @@ public struct CopyButton: View { } .padding(4) .simultaneousGesture( - TapGesture().onEnded { _ in - withAnimation(.linear(duration: 0.1)) { - isCopied = true - } - copy() - Task { - try await Task.sleep(nanoseconds: 1_000_000_000) + TapGesture() + .onEnded { _ in withAnimation(.linear(duration: 0.1)) { - isCopied = false + isCopied = true + } + copy() + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation(.linear(duration: 0.1)) { + isCopied = false + } } } - } ) } } diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift index 1245d98ff..6e04309ab 100644 --- a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -6,6 +6,7 @@ public class AppInstanceInspector: ObservableObject { public let processIdentifier: pid_t public let bundleURL: URL? public let bundleIdentifier: String? + public let name: String public var appElement: AXUIElement { let app = AXUIElementCreateApplication(runningApplication.processIdentifier) @@ -38,6 +39,7 @@ public class AppInstanceInspector: ObservableObject { init(runningApplication: NSRunningApplication) { self.runningApplication = runningApplication + name = runningApplication.localizedName ?? "Unknown" processIdentifier = runningApplication.processIdentifier bundleURL = runningApplication.bundleURL bundleIdentifier = runningApplication.bundleIdentifier diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 76ee96a68..3150ed49b 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -423,9 +423,6 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool { public extension AXUIElement { var tabBars: [AXUIElement] { - // Searching by traversing with AXUIElement is (Xcode) resource consuming, we should skip - // as much as possible! - guard let editArea: AXUIElement = { if description == "editor area" { return self } return firstChild(where: { $0.description == "editor area" }) @@ -471,4 +468,44 @@ public extension AXUIElement { 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 } + + var debugArea: AXUIElement? + editArea.traverse { element, _ in + let description = element.description + if description == "Tab Bar" { + return .skipDescendants + } + + if element.identifier == "editor context" { + return .skipDescendantsAndSiblings + } + + if element.isSourceEditor { + return .skipDescendantsAndSiblings + } + + if description == "Code Coverage Ribbon" { + return .skipDescendants + } + + if description == "Debug Area" { + debugArea = element + return .skipDescendants + } + + if description == "debug bar" { + return .skipDescendants + } + + return .continueSearching + } + + return debugArea + } } diff --git a/Version.xcconfig b/Version.xcconfig index 0627718ec..149df3ac9 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.35.0 -APP_BUILD = 427 +APP_VERSION = 0.35.2 +APP_BUILD = 429