diff --git a/Core/Package.swift b/Core/Package.swift index 1661a76..b6a3fec 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -191,7 +191,8 @@ let package = Package( .product(name: "Cache", package: "Tool"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout") + .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), + .product(name: "Persist", package: "Tool") ] ), .testTarget( diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 62e6383..e9228c0 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -10,7 +10,7 @@ import Status public protocol ChatServiceType { var memory: ContextAwareAutoManagedChatMemory { get set } - func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference]) async throws + func send(_ id: String, content: String, skillSet: [ConversationSkill], references: [FileReference], model: String?) async throws func stopReceivingMessage() async func upvote(_ id: String, _ rating: ConversationRating) async func downvote(_ id: String, _ rating: ConversationRating) async @@ -82,7 +82,7 @@ public final class ChatService: ChatServiceType, ObservableObject { return ChatService(provider: provider) } - public func send(_ id: String, content: String, skillSet: Array, references: Array) async throws { + public func send(_ id: String, content: String, skillSet: Array, references: Array, model: String? = nil) async throws { guard activeRequestId == nil else { return } let workDoneToken = UUID().uuidString activeRequestId = workDoneToken @@ -115,7 +115,8 @@ public final class ChatService: ChatServiceType, ObservableObject { workspaceFolder: "", skills: skillCapabilities, ignoredSkills: ignoredSkills, - references: references) + references: references, + model: model) self.skillSet = skillSet try await send(request) } @@ -258,6 +259,11 @@ public final class ChatService: ChatServiceType, ObservableObject { return nil } + public func copilotModels() async -> [CopilotModel] { + guard let models = try? await conversationProvider?.models() else { return [] } + return models + } + public func handleSingleRoundDialogCommand( systemPrompt: String?, overwriteSystemPrompt: Bool, @@ -334,6 +340,16 @@ public final class ChatService: ChatServiceType, ObservableObject { await memory.removeMessage(progress.turnId) await memory.appendMessage(errorMessage) } + } else if CLSError.code == 400 && CLSError.message.contains("model is not supported") { + Task { + let errorMessage = ChatMessage( + id: progress.turnId, + role: .assistant, + content: "", + errorMessage: "Oops, the model is not supported. Please enable it first in [GitHub Copilot settings](https://github.com/settings/copilot)." + ) + await memory.appendMessage(errorMessage) + } } else { Task { let errorMessage = ChatMessage( diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift index 819ce13..6475ddd 100644 --- a/Core/Sources/ConversationTab/Chat.swift +++ b/Core/Sources/ConversationTab/Chat.swift @@ -5,6 +5,7 @@ import ChatAPIService import Preferences import Terminal import ConversationServiceProvider +import Persist public struct DisplayedChatMessage: Equatable { public enum Role: Equatable { @@ -140,9 +141,9 @@ struct Chat { state.typedMessage = "" let selectedFiles = state.selectedFiles - + let selectedModelFamily = AppState.shared.getSelectedModelFamily() return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles) + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily) }.cancellable(id: CancelID.sendMessage(self.id)) case let .followUpButtonClicked(id, message): @@ -150,9 +151,10 @@ struct Chat { let skillSet = state.buildSkillSet() let selectedFiles = state.selectedFiles + let selectedModelFamily = AppState.shared.getSelectedModelFamily() return .run { _ in - try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles) + try await service.send(id, content: message, skillSet: skillSet, references: selectedFiles, model: selectedModelFamily) }.cancellable(id: CancelID.sendMessage(self.id)) case .returnButtonTapped: diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift index 61efdce..ef74341 100644 --- a/Core/Sources/ConversationTab/ChatPanel.swift +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -31,7 +31,7 @@ public struct ChatPanel: View { } else { ChatPanelMessages(chat: chat) .accessibilityElement(children: .combine) - .accessibilityLabel("Chat Mesessages Group") + .accessibilityLabel("Chat Messages Group") if chat.history.last?.role == .system { ChatCLSError(chat: chat).padding(.trailing, 16) @@ -567,6 +567,7 @@ struct ChatPanelInputArea: View { Spacer() + ModelPicker() Button(action: { submitChatMessage() }) { @@ -676,7 +677,7 @@ struct ChatPanelInputArea: View { id: "releaseNotes", description: "What's New", shortDescription: "What's New", - scopes: [ChatPromptTemplateScope.chatPanel] + scopes: [PromptTemplateScope.chatPanel] ) guard !promptTemplates.isEmpty else { diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift index a1c1c29..15e440c 100644 --- a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift +++ b/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift @@ -1,6 +1,7 @@ import ConversationServiceProvider import AppKit import SwiftUI +import ComposableArchitecture public struct ChatTemplateDropdownView: View { @Binding var templates: [ChatTemplate] @@ -10,76 +11,78 @@ public struct ChatTemplateDropdownView: View { @State private var localMonitor: Any? = nil public var body: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in - HStack { - Text("/" + template.id) - .hoverPrimaryForeground(isHovered: selectedIndex == index) - Spacer() - Text(template.shortDescription) - .hoverSecondaryForeground(isHovered: selectedIndex == index) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - .onTapGesture { - onSelect(template) - } - .hoverBackground(isHovered: selectedIndex == index) - .onHover { isHovered in - if isHovered { - selectedIndex = index + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in + HStack { + Text("/" + template.id) + .hoverPrimaryForeground(isHovered: selectedIndex == index) + Spacer() + Text(template.shortDescription) + .hoverSecondaryForeground(isHovered: selectedIndex == index) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { + onSelect(template) + } + .hoverBackground(isHovered: selectedIndex == index) + .onHover { isHovered in + if isHovered { + selectedIndex = index + } } } } - } - .background( - GeometryReader { geometry in - Color.clear - .onAppear { frameHeight = geometry.size.height } - .onChange(of: geometry.size.height) { newHeight in - frameHeight = newHeight - } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { frameHeight = geometry.size.height } + .onChange(of: geometry.size.height) { newHeight in + frameHeight = newHeight + } + } + ) + .background(.ultraThickMaterial) + .cornerRadius(6) + .shadow(radius: 2) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .frame(maxWidth: .infinity) + .offset(y: -1 * frameHeight) + .onChange(of: templates) { _ in + selectedIndex = 0 } - ) - .background(.ultraThickMaterial) - .cornerRadius(6) - .shadow(radius: 2) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .frame(maxWidth: .infinity) - .offset(y: -1 * frameHeight) - .onChange(of: templates) { _ in - selectedIndex = 0 - } - .onAppear { - selectedIndex = 0 - localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - switch event.keyCode { - case 126: // Up arrow - moveSelection(up: true) - return nil - case 125: // Down arrow - moveSelection(up: false) - return nil - case 36: // Return key - handleEnter() - return nil - case 48: // Tab key - handleTab() - return nil // not forwarding the Tab Event which will replace the typed message to "\t" - default: - break + .onAppear { + selectedIndex = 0 + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + switch event.keyCode { + case 126: // Up arrow + moveSelection(up: true) + return nil + case 125: // Down arrow + moveSelection(up: false) + return nil + case 36: // Return key + handleEnter() + return nil + case 48: // Tab key + handleTab() + return nil // not forwarding the Tab Event which will replace the typed message to "\t" + default: + break + } + return event } - return event } - } - .onDisappear { - if let monitor = localMonitor { - NSEvent.removeMonitor(monitor) - localMonitor = nil + .onDisappear { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } } } } diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift index 6db7e9a..3a7349e 100644 --- a/Core/Sources/ConversationTab/ContextUtils.swift +++ b/Core/Sources/ConversationTab/ContextUtils.swift @@ -3,7 +3,7 @@ import XcodeInspector import Foundation import Logger -public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements"] +public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml"] private let skipPatterns: [String] = [ ".git", ".svn", diff --git a/Core/Sources/ConversationTab/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker.swift new file mode 100644 index 0000000..effd622 --- /dev/null +++ b/Core/Sources/ConversationTab/ModelPicker.swift @@ -0,0 +1,102 @@ +import SwiftUI +import ChatService +import Persist +import ComposableArchitecture + +public let SELECTED_LLM_KEY = "selectedLLM" + +extension AppState { + func getSelectedModelFamily() -> String? { + if let savedModel = get(key: SELECTED_LLM_KEY), + let modelFamily = savedModel["modelFamily"]?.stringValue { + return modelFamily + } + return nil + } + + func getSelectedModelName() -> String? { + if let savedModel = get(key: SELECTED_LLM_KEY), + let modelName = savedModel["modelName"]?.stringValue { + return modelName + } + return nil + } + + func setSelectedModel(_ model: LLMModel) { + update(key: SELECTED_LLM_KEY, value: model) + } +} + +struct LLMModel: Codable, Hashable { + let modelName: String + let modelFamily: String +} + +let defaultModel = LLMModel(modelName: "GPT-4o", modelFamily: "gpt-4o") +struct ModelPicker: View { + @State private var selectedModel = defaultModel.modelName + @State private var models: [LLMModel] = [ defaultModel ] + @State private var isHovered = false + @State private var isPressed = false + + init() { + self.updateCurrentModel() + } + + func updateCurrentModel() { + selectedModel = AppState.shared.getSelectedModelName() ?? defaultModel.modelName + } + + var body: some View { + WithPerceptionTracking { + Menu(selectedModel) { + ForEach(models, id: \.self) { option in + Button { + selectedModel = option.modelName + AppState.shared.setSelectedModel(option) + } label: { + if selectedModel == option.modelName { + Text("✓ \(option.modelName)") + } else { + Text(" \(option.modelName)") + } + } + } + } + .menuStyle(BorderlessButtonMenuStyle()) + .frame(maxWidth: labelWidth()) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + isHovered = hovering + } + .onAppear() { + Task { + updateCurrentModel() + self.models = await ChatService.shared.copilotModels().filter( + { $0.scopes.contains(.chatPanel) } + ).map { + LLMModel(modelName: $0.modelName, modelFamily: $0.modelFamily) + } + } + } + .help("Pick Model") + } + } + + func labelWidth() -> CGFloat { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + let attributes = [NSAttributedString.Key.font: font] + let width = selectedModel.size(withAttributes: attributes).width + return CGFloat(width + 20) + } +} + +struct ModelPicker_Previews: PreviewProvider { + static var previews: some View { + ModelPicker() + } +} diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift index 15c2826..a2cd632 100644 --- a/Core/Sources/ConversationTab/Views/BotMessage.swift +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -120,8 +120,7 @@ struct BotMessage: View { if errorMessage != nil { HStack(spacing: 4) { Image(systemName: "info.circle") - Text(errorMessage!) - .font(.system(size: chatFontSize)) + ThemedMarkdownText(text: errorMessage!, chat: chat) } } diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift index 80bfcf5..a3c6605 100644 --- a/Core/Sources/HostApp/General.swift +++ b/Core/Sources/HostApp/General.swift @@ -13,6 +13,7 @@ struct General { struct State: Equatable { var xpcServiceVersion: String? var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown + var isExtensionPermissionGranted: ExtensionPermissionStatus = .unknown var isReloading = false } @@ -21,7 +22,11 @@ struct General { case setupLaunchAgentIfNeeded case openExtensionManager case reloadStatus - case finishReloading(xpcServiceVersion: String, permissionGranted: ObservedAXStatus) + case finishReloading( + xpcServiceVersion: String, + axStatus: ObservedAXStatus, + extensionStatus: ExtensionPermissionStatus + ) case failedReloading case retryReloading } @@ -84,9 +89,11 @@ struct General { let xpcServiceVersion = try await service.getXPCServiceVersion().version let isAccessibilityPermissionGranted = try await service .getXPCServiceAccessibilityPermission() + let isExtensionPermissionGranted = try await service.getXPCServiceExtensionPermission() await send(.finishReloading( xpcServiceVersion: xpcServiceVersion, - permissionGranted: isAccessibilityPermissionGranted + axStatus: isAccessibilityPermissionGranted, + extensionStatus: isExtensionPermissionGranted )) } else { toast("Launching service app.", .info) @@ -107,9 +114,10 @@ struct General { } }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) - case let .finishReloading(version, granted): + case let .finishReloading(version, axStatus, extensionStatus): state.xpcServiceVersion = version - state.isAccessibilityPermissionGranted = granted + state.isAccessibilityPermissionGranted = axStatus + state.isExtensionPermissionGranted = extensionStatus state.isReloading = false return .none diff --git a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift index 4f51acc..6c26482 100644 --- a/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift +++ b/Core/Sources/HostApp/GeneralSettings/GeneralSettingsView.swift @@ -5,6 +5,7 @@ struct GeneralSettingsView: View { @AppStorage(\.extensionPermissionShown) var extensionPermissionShown: Bool @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) var quitXPCServiceOnXcodeAndAppQuit: Bool @State private var shouldPresentExtensionPermissionAlert = false + @State private var shouldShowRestartXcodeAlert = false let store: StoreOf @@ -13,11 +14,53 @@ struct GeneralSettingsView: View { case .granted: return "Granted" case .notGranted: - return "Not Granted. Required to run. Click to open System Preferences." + return "Enable accessibility in system preferences" case .unknown: return "" } } + + var extensionPermissionSubtitle: any View { + switch store.isExtensionPermissionGranted { + case .notGranted: + return HStack(spacing: 0) { + Text("Enable ") + Text( + "Extensions \(Image(systemName: "puzzlepiece.extension.fill")) → Xcode Source Editor \(Image(systemName: "info.circle")) → GitHub Copilot for Xcode" + ) + .bold() + .foregroundStyle(.primary) + Text(" for faster and full-featured code completion.") + } + case .disabled: + return Text("Quit and restart Xcode to enable extension") + case .granted: + return Text("Granted") + case .unknown: + return Text("") + } + } + + + var extensionPermissionBadge: BadgeItem? { + switch store.isExtensionPermissionGranted { + case .notGranted: + return .init(text: "Not Granted", level: .danger) + case .disabled: + return .init(text: "Disabled", level: .danger) + default: + return nil + } + } + + var extensionPermissionAction: ()->Void { + switch store.isExtensionPermissionGranted { + case .disabled: + return { shouldShowRestartXcodeAlert = true } + default: + return NSWorkspace.openXcodeExtensionsPreferences + } + } var body: some View { SettingsSection(title: "General") { @@ -38,12 +81,10 @@ struct GeneralSettingsView: View { ) Divider() SettingsLink( - url: "x-apple.systempreferences:com.apple.ExtensionsPreferences", + action: extensionPermissionAction, title: "Extension Permission", - subtitle: """ - Check for GitHub Copilot in Xcode's Editor menu. \ - Restart Xcode if greyed out. - """ + subtitle: extensionPermissionSubtitle, + badge: extensionPermissionBadge ) } footer: { HStack { @@ -60,19 +101,36 @@ struct GeneralSettingsView: View { "Enable Extension Permission", isPresented: $shouldPresentExtensionPermissionAlert ) { - Button("Open System Preferences", action: { - let url = "x-apple.systempreferences:com.apple.ExtensionsPreferences" - NSWorkspace.shared.open(URL(string: url)!) + Button( + "Open System Preferences", + action: { + NSWorkspace.openXcodeExtensionsPreferences() }).keyboardShortcut(.defaultAction) + Button("View How-to Guide", action: { + let url = "https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission" + NSWorkspace.shared.open(URL(string: url)!) + }) Button("Close", role: .cancel, action: {}) } message: { - Text("Enable GitHub Copilot under Xcode Source Editor extensions") + Text("To enable faster and full-featured code completion, navigate to:\nExtensions → Xcode Source Editor → GitHub Copilot for Xcode.") } .task { if extensionPermissionShown { return } extensionPermissionShown = true shouldPresentExtensionPermissionAlert = true } + .alert( + "Restart Xcode?", + isPresented: $shouldShowRestartXcodeAlert + ) { + Button("Restart Now") { + NSWorkspace.restartXcode() + }.keyboardShortcut(.defaultAction) + + Button("Cancel", role: .cancel) {} + } message: { + Text("Quit and restart Xcode to enable Github Copilot for Xcode extension.") + } } } diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift index 564fdad..8f5d777 100644 --- a/Core/Sources/HostApp/HandleToast.swift +++ b/Core/Sources/HostApp/HandleToast.swift @@ -17,16 +17,7 @@ struct ToastHandler: View { if let n = message.namespace, n != namespace { EmptyView() } else { - message.content - .foregroundColor(.white) - .padding(8) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) + NotificationView(message: message) .shadow(color: Color.black.opacity(0.2), radius: 4) } } @@ -41,8 +32,8 @@ extension View { @Dependency(\.toastController) var toastController return overlay(alignment: .bottom) { ToastHandler(toastController: toastController, namespace: namespace) - }.environment(\.toast) { [toastController] content, type in - toastController.toast(content: content, type: type, namespace: namespace) + }.environment(\.toast) { [toastController] content, level in + toastController.toast(content: content, level: level, namespace: namespace) } } } diff --git a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift index b2d358e..32fb296 100644 --- a/Core/Sources/HostApp/SharedComponents/SettingsLink.swift +++ b/Core/Sources/HostApp/SharedComponents/SettingsLink.swift @@ -1,29 +1,38 @@ import SwiftUI struct SettingsLink: View { - let url: URL + let action: ()->Void let title: String - let subtitle: String? + let subtitle: AnyView? let badge: BadgeItem? - init( - _ url: URL, + init( + action: @escaping ()->Void, title: String, - subtitle: String? = nil, + subtitle: Subtitle?, badge: BadgeItem? = nil ) { - self.url = url + self.action = action self.title = title - self.subtitle = subtitle + self.subtitle = subtitle.map { AnyView($0) } self.badge = badge } - + init( - url: String, + _ url: URL, title: String, subtitle: String? = nil, badge: BadgeItem? = nil ) { + self.init( + action: { NSWorkspace.shared.open(url) }, + title: title, + subtitle: subtitle.map { Text($0) }, + badge: badge + ) + } + + init(url: String, title: String, subtitle: String? = nil, badge: BadgeItem? = nil) { self.init( URL(string: url)!, title: title, @@ -31,24 +40,36 @@ struct SettingsLink: View { badge: badge ) } + + init(url: String, title: String, subtitle: Subtitle?, badge: BadgeItem? = nil) { + self.init( + action: { NSWorkspace.shared.open(URL(string: url)!) }, + title: title, + subtitle: subtitle, + badge: badge + ) + } var body: some View { - Link(destination: url) { - VStack(alignment: .leading) { - HStack{ - Text(title).font(.body) - if let badge = self.badge { - Badge(badgeItem: badge) + Button(action: action) { + HStack{ + VStack(alignment: .leading) { + HStack{ + Text(title).font(.body) + if let badge = self.badge { + Badge(badgeItem: badge) + } + } + if let subtitle = subtitle { + subtitle.font(.footnote) } } - if let subtitle = subtitle { - Text(subtitle) - .font(.footnote) - } + Spacer() + Image(systemName: "chevron.right") } - Spacer() - Image(systemName: "chevron.right") + .contentShape(Rectangle()) // This makes the entire HStack clickable } + .buttonStyle(.plain) .foregroundStyle(.primary) .padding(10) } diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 0d3f0a8..ae71213 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -225,9 +225,9 @@ struct TabContainer_Toasts_Previews: PreviewProvider { TabContainer( store: .init(initialState: .init(), reducer: { HostApp() }), toastController: .init(messages: [ - .init(id: UUID(), type: .info, content: Text("info")), - .init(id: UUID(), type: .error, content: Text("error")), - .init(id: UUID(), type: .warning, content: Text("warning")), + .init(id: UUID(), level: .info, content: Text("info")), + .init(id: UUID(), level: .error, content: Text("error")), + .init(id: UUID(), level: .warning, content: Text("warning")), ]) ) .frame(width: 800) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index e285ba5..87b3114 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -125,12 +125,18 @@ public actor RealtimeSuggestionController { do { try await XcodeInspector.shared.safe.latestActiveXcode? .triggerCopilotCommand(name: "Sync Text Settings") - await Status.shared.updateExtensionStatus(.succeeded) + await Status.shared.updateExtensionStatus(.granted) } catch { if filespace.codeMetadata.uti?.isEmpty ?? true { filespace.codeMetadata.uti = nil } - await Status.shared.updateExtensionStatus(.failed) + if let cantRunError = error as? AppInstanceInspector.CantRunCommand { + if cantRunError.errorDescription.contains("No bundle found") { + await Status.shared.updateExtensionStatus(.notGranted) + } else if cantRunError.errorDescription.contains("found but disabled") { + await Status.shared.updateExtensionStatus(.disabled) + } + } } } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 463f7e9..6e0bec6 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -107,7 +107,7 @@ public final class Service { await XcodeInspector.shared.safe.$activeWorkspaceURL.receive(on: DispatchQueue.main) .sink { newURL in - if let path = newURL?.path, self.guiController.store.chatHistory.selectedWorkspacePath != path { + if let path = newURL?.path, path != "/", self.guiController.store.chatHistory.selectedWorkspacePath != path { let name = self.getDisplayNameOfXcodeWorkspace(url: newURL!) self.guiController.store.send(.switchWorkspace(path: path, name: name)) } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f919ae7..91df57a 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -16,6 +16,8 @@ import AXHelper /// For example, we can use it to generate real-time suggestions without Apple Scripts. struct PseudoCommandHandler { static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) + static var lastBundleNotFoundTime = Date(timeIntervalSince1970: 0) + static var lastBundleDisabledTime = Date(timeIntervalSince1970: 0) private var toast: ToastController { ToastControllerDependencyKey.liveValue } func presentPreviousSuggestion() async { @@ -52,7 +54,7 @@ struct PseudoCommandHandler { func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { guard let filespace = await getFilespace(), let (workspace, _) = try? await Service.shared.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } + .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } @@ -201,14 +203,14 @@ struct PseudoCommandHandler { The app is using a fallback solution to accept suggestions. \ For better experience, please restart Xcode to re-activate the Copilot \ menu item. - """, type: .warning) + """, level: .warning) } throw error } } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return } + ?? ActiveApplicationMonitor.shared.latestXcode else { return } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" @@ -255,21 +257,49 @@ struct PseudoCommandHandler { try await XcodeInspector.shared.safe.latestActiveXcode? .triggerCopilotCommand(name: "Accept Suggestion") } catch { - let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let lastBundleNotFoundTime = Self.lastBundleNotFoundTime + let lastBundleDisabledTime = Self.lastBundleDisabledTime let now = Date() - if now.timeIntervalSince(last) > 60 * 60 { - Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now - toast.toast(content: """ - Xcode is relying on a fallback solution for Copilot suggestions. \ - For optimal performance, please restart Xcode to reactivate Copilot. - """, type: .warning) + if let cantRunError = error as? AppInstanceInspector.CantRunCommand { + if cantRunError.errorDescription.contains("No bundle found") { + // Extension permission not granted + if now.timeIntervalSince(lastBundleNotFoundTime) > 60 * 60 { + Self.lastBundleNotFoundTime = now + toast.toast( + title: "Extension Permission Not Granted", + content: """ + Enable Extensions → Xcode Source Editor → GitHub Copilot \ + for Xcode for faster and full-featured code completion. \ + [View How-to Guide](https://github.com/github/CopilotForXcode/blob/main/TROUBLESHOOTING.md#extension-permission) + """, + level: .warning, + button: .init( + title: "Enable", + action: { NSWorkspace.openXcodeExtensionsPreferences() } + ) + ) + } + } else if cantRunError.errorDescription.contains("found but disabled") { + if now.timeIntervalSince(lastBundleDisabledTime) > 60 * 60 { + Self.lastBundleDisabledTime = now + toast.toast( + title: "GitHub Copilot Extension Disabled", + content: "Quit and restart Xcode to enable extension.", + level: .warning, + button: .init( + title: "Restart", + action: { NSWorkspace.restartXcode() } + ) + ) + } + } } throw error } } catch { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return } + ?? ActiveApplicationMonitor.shared.latestXcode else { return } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" @@ -339,20 +369,20 @@ extension PseudoCommandHandler { PresentInWindowSuggestionPresenter() .presentErrorMessage("Fail to set editor content.") } - ) + ) } func getFileContent(sourceEditor: AXUIElement?) async - -> ( - content: String, - lines: [String], - selections: [CursorRange], - cursorPosition: CursorPosition, - cursorOffset: Int - )? + -> ( + content: String, + lines: [String], + selections: [CursorRange], + cursorPosition: CursorPosition, + cursorOffset: Int + )? { guard let xcode = ActiveApplicationMonitor.shared.activeXcode - ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } + ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = sourceEditor ?? application.focusedElement, focusElement.description == "Source Editor" @@ -373,7 +403,7 @@ extension PseudoCommandHandler { guard let fileURL = await getFileURL(), let (_, filespace) = try? await Service.shared.workspacePool - .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } return filespace } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 49cab1f..0ae2104 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -22,6 +22,14 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(await Status.shared.getAXStatus()) } } + + public func getXPCServiceExtensionPermission( + withReply reply: @escaping (ExtensionPermissionStatus) -> Void + ) { + Task { + reply(await Status.shared.getExtensionStatus()) + } + } // MARK: - Suggestion diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift index 6e9ffab..ee648ef 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -4,42 +4,48 @@ import Foundation import SwiftUI import Toast +private struct HitTestConfiguration: ViewModifier { + let hitTestPredicate: () -> Bool + + func body(content: Content) -> some View { + WithPerceptionTracking { + content.allowsHitTesting(hitTestPredicate()) + } + } +} + struct ToastPanelView: View { let store: StoreOf + @Dependency(\.toastController) var toastController var body: some View { WithPerceptionTracking { VStack(spacing: 4) { if !store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } ForEach(store.toast.messages) { message in - message.content - .foregroundColor(.white) - .padding(8) - .frame(maxWidth: .infinity) - .background({ - switch message.type { - case .info: return Color.accentColor - case .error: return Color(nsColor: .systemRed) - case .warning: return Color(nsColor: .systemOrange) - } - }() as Color, in: RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8) - .stroke(Color.black.opacity(0.3), lineWidth: 1) - } + NotificationView( + message: message, + onDismiss: { toastController.dismissMessage(withId: message.id) } + ) + .frame(maxWidth: 450) + // Allow hit testing for notification views + .allowsHitTesting(true) } if store.alignTopToAnchor { Spacer() + .allowsHitTesting(false) } } .colorScheme(store.colorScheme) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) + .background(Color.clear) + // Only allow hit testing when there are messages + // to prevent the view from blocking the mouse events + .modifier(HitTestConfiguration(hitTestPredicate: { !store.toast.messages.isEmpty })) } } } - diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 6328530..c661c82 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -732,6 +732,8 @@ public final class WidgetWindows { }() @MainActor + // The toast window area is now capturing mouse events + // Even in the transparent parts where there's no visible content. lazy var toastWindow = { let it = CanBecomeKeyWindow( contentRect: .zero, @@ -740,7 +742,7 @@ public final class WidgetWindows { defer: false ) it.isReleasedWhenClosed = false - it.isOpaque = true + it.isOpaque = false it.backgroundColor = .clear it.level = widgetLevel(0) it.collectionBehavior = [.fullScreenAuxiliary, .transient, .canJoinAllSpaces] @@ -752,7 +754,6 @@ public final class WidgetWindows { )) ) it.setIsVisible(true) - it.ignoresMouseEvents = true it.canBecomeKeyChecker = { false } return it }() diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift index 65fb242..941a6c8 100644 --- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -19,8 +19,11 @@ class FilespaceSuggestionInvalidationTests: XCTestCase { range: CursorRange = .init(startPair: (1, 0), endPair: (1, 0)) ) async throws -> (Filespace, FilespaceSuggestionSnapshot) { let pool = WorkspacePool() - let (_, filespace) = try await pool - .fetchOrCreateWorkspaceAndFilespace(fileURL: URL(fileURLWithPath: "file/path/to.swift")) + let filespace = Filespace( + fileURL: URL(fileURLWithPath: "file/path/to.swift"), + onSave: { _ in }, + onClose: { _ in } + ) filespace.suggestions = [ .init( id: "", diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index aa1e9b0..2cf5460 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -31,16 +31,6 @@ extension AppDelegate { let statusBarMenu = NSMenu(title: "Status Bar Menu") statusBarMenu.identifier = statusBarMenuIdentifier statusBarItem.menu = statusBarMenu - - let boldTitle = NSAttributedString( - string: "Github Copilot", - attributes: [ - .font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), - .foregroundColor: NSColor(.primary) - ] - ) - let attributedTitle = NSMenuItem() - attributedTitle.attributedTitle = boldTitle let checkForUpdate = NSMenuItem( title: "Check for Updates", @@ -67,11 +57,18 @@ extension AppDelegate { axStatusItem = NSMenuItem( title: "", - action: #selector(openExtensionStatusLink), + action: #selector(openAXStatusLink), keyEquivalent: "" ) axStatusItem.isHidden = true + extensionStatusItem = NSMenuItem( + title: "", + action: #selector(openExtensionStatusLink), + keyEquivalent: "" + ) + extensionStatusItem.isHidden = true + let quitItem = NSMenuItem( title: "Quit", action: #selector(quit), @@ -103,14 +100,14 @@ extension AppDelegate { action: nil, keyEquivalent: "" ) - axStatusItem.isHidden = true + authStatusItem.isHidden = true upSellItem = NSMenuItem( title: "", action: #selector(openUpSellLink), keyEquivalent: "" ) - axStatusItem.isHidden = true + upSellItem.isHidden = true let openDocs = NSMenuItem( title: "View Documentation", @@ -136,20 +133,20 @@ extension AppDelegate { keyEquivalent: "" ) - statusBarMenu.addItem(attributedTitle) statusBarMenu.addItem(accountItem) statusBarMenu.addItem(authStatusItem) statusBarMenu.addItem(upSellItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(axStatusItem) - statusBarMenu.addItem(.separator()) - statusBarMenu.addItem(openCopilotForXcodeItem) + statusBarMenu.addItem(extensionStatusItem) statusBarMenu.addItem(.separator()) statusBarMenu.addItem(checkForUpdate) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openChat) statusBarMenu.addItem(toggleCompletions) statusBarMenu.addItem(toggleIgnoreLanguage) - statusBarMenu.addItem(openChat) statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openCopilotForXcodeItem) statusBarMenu.addItem(openDocs) statusBarMenu.addItem(openForum) statusBarMenu.addItem(.separator()) @@ -328,14 +325,26 @@ private extension AppDelegate { } } - @objc func openExtensionStatusLink() { + @objc func openAXStatusLink() { Task { - let status = await Status.shared.getStatus() - if let s = status.url, let url = URL(string: s) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { NSWorkspace.shared.open(url) } } } + + @objc func openExtensionStatusLink() { + Task { + let status = await Status.shared.getExtensionStatus() + if status == .notGranted { + if let url = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.dt.Xcode.extension.source-editor") { + NSWorkspace.shared.open(url) + } + } else { + NSWorkspace.restartXcode() + } + } + } @objc func openUpSellLink() { Task { diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index 97d4434..e72468c 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -34,6 +34,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let service = Service.shared var statusBarItem: NSStatusItem! var axStatusItem: NSMenuItem! + var extensionStatusItem: NSMenuItem! var openCopilotForXcodeItem: NSMenuItem! var accountItem: NSMenuItem! var authStatusItem: NSMenuItem! @@ -268,7 +269,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.upSellItem.isHidden = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true - self.openChat.isHidden = true self.signOutItem.isHidden = true } @@ -310,7 +310,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false - self.openChat.isHidden = false self.signOutItem.isHidden = false } @@ -339,7 +338,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.upSellItem.isEnabled = true self.toggleCompletions.isHidden = true self.toggleIgnoreLanguage.isHidden = true - self.openChat.isHidden = true self.signOutItem.isHidden = false } @@ -353,7 +351,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self.upSellItem.isHidden = true self.toggleCompletions.isHidden = false self.toggleIgnoreLanguage.isHidden = false - self.openChat.isHidden = false self.signOutItem.isHidden = false } @@ -372,14 +369,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } /// Update accessibility permission status bar item + let exclamationmarkImage = NSImage( + systemSymbolName: "exclamationmark.circle.fill", + accessibilityDescription: "Permission not granted" + ) + exclamationmarkImage?.isTemplate = false + exclamationmarkImage?.withSymbolConfiguration(.init(paletteColors: [.red])) + if let message = status.message { self.axStatusItem.title = message - if let image = NSImage( - systemSymbolName: "exclamationmark.circle.fill", - accessibilityDescription: "Accessibility permission not granted" - ) { - image.isTemplate = false - image.withSymbolConfiguration(.init(paletteColors: [.red])) + if let image = exclamationmarkImage { self.axStatusItem.image = image } self.axStatusItem.isHidden = false @@ -389,17 +388,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } /// Update settings status bar item - if status.extensionStatus == .failed { - if let image = NSImage( - systemSymbolName: "exclamationmark.circle.fill", - accessibilityDescription: "Extension permission not granted" - ) { - image.isTemplate = false - image.withSymbolConfiguration(.init(paletteColors: [.red])) - self.openCopilotForXcodeItem.image = image + if status.extensionStatus == .disabled || status.extensionStatus == .notGranted { + if let image = exclamationmarkImage{ + if #available(macOS 15.0, *){ + self.extensionStatusItem.image = image + self.extensionStatusItem.title = status.extensionStatus == .notGranted ? "Enable extension for full-featured completion" : "Quit and restart Xcode to enable extension" + self.extensionStatusItem.isHidden = false + self.extensionStatusItem.isEnabled = status.extensionStatus == .notGranted + } else { + self.extensionStatusItem.isHidden = true + self.openCopilotForXcodeItem.image = image + } } } else { self.openCopilotForXcodeItem.image = nil + self.extensionStatusItem.isHidden = true } self.markAsProcessing(status.inProgress) } diff --git a/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json new file mode 100644 index 0000000..41903f4 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastActionButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x3C", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastBackgroundColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastBackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..ee9f736 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF7", + "green" : "0xF7", + "red" : "0xF7" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x23", + "green" : "0x23", + "red" : "0x23" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json new file mode 100644 index 0000000..ab8dfaf --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastDismissButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.500", + "green" : "0.500", + "red" : "0.500" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.800", + "green" : "0.800", + "red" : "0.800" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json new file mode 100644 index 0000000..2a52454 --- /dev/null +++ b/ExtensionService/Assets.xcassets/ToastStrokeColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.550", + "blue" : "0xC0", + "green" : "0xC0", + "red" : "0xC0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x2B", + "red" : "0x2B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Server/package-lock.json b/Server/package-lock.json index 149cd7e..a70a8c0 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -8,13 +8,13 @@ "name": "@github/copilot-xcode", "version": "0.0.1", "dependencies": { - "@github/copilot-language-server": "^1.273.0" + "@github/copilot-language-server": "^1.277.0" } }, "node_modules/@github/copilot-language-server": { - "version": "1.273.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.273.0.tgz", - "integrity": "sha512-aVpGOprSPYYgRHzjnim5ug1L0SSlTEwLlYNITFTCIaVJmNr5T8gdq09ZiDlZXKQHlo7kQty+WhHZCAZd9bor+w==", + "version": "1.277.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.277.0.tgz", + "integrity": "sha512-dd24KSCwgLx6Bep7nmgAMpLTmiHclsLm1IUHlQGsCMxlDtthUuB2i37K265HHkyvsv0gLC2jLNyoH4f+jkreAA==", "license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" diff --git a/Server/package.json b/Server/package.json index c868fd5..b26f7b2 100644 --- a/Server/package.json +++ b/Server/package.json @@ -4,6 +4,6 @@ "description": "Package for downloading @github/copilot-language-server", "private": true, "dependencies": { - "@github/copilot-language-server": "^1.273.0" + "@github/copilot-language-server": "^1.277.0" } } diff --git a/Tool/Package.swift b/Tool/Package.swift index 698e609..2ec314f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -19,6 +19,7 @@ let package = Package( .library(name: "Toast", targets: ["Toast"]), .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), .library(name: "Status", targets: ["Status"]), + .library(name: "Persist", targets: ["Persist"]), .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), .library( @@ -240,6 +241,14 @@ let package = Package( dependencies: ["Cache"] ), + .target( + name: "Persist", + dependencies: [ + "Logger", + "Status" + ] + ), + .target(name: "SuggestionProvider", dependencies: [ "SuggestionBasic", "UserDefaultsObserver", diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 7354ef2..f4b3f19 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -130,7 +130,7 @@ public final class AXNotificationStream: AsyncSequence { pendingRegistrationNames.remove(name) await Status.shared.updateAXStatus(.granted) case .actionUnsupported: - Logger.service.error("AXObserver: Action unsupported: \(name)") + Logger.service.info("AXObserver: Action unsupported: \(name)") pendingRegistrationNames.remove(name) case .apiDisabled: if shouldLogAXDisabledEvent { // Avoid keeping log AX disabled too many times @@ -142,23 +142,23 @@ public final class AXNotificationStream: AsyncSequence { await Status.shared.updateAXStatus(.notGranted) case .invalidUIElement: Logger.service - .error("AXObserver: Invalid UI element, notification name \(name)") + .info("AXObserver: Invalid UI element, notification name \(name)") pendingRegistrationNames.remove(name) case .invalidUIElementObserver: - Logger.service.error("AXObserver: Invalid UI element observer") + Logger.service.info("AXObserver: Invalid UI element observer") pendingRegistrationNames.remove(name) case .cannotComplete: Logger.service - .error("AXObserver: Failed to observe \(name), will try again later") + .info("AXObserver: Failed to observe \(name), will try again later") case .notificationUnsupported: - Logger.service.error("AXObserver: Notification unsupported: \(name)") + Logger.service.info("AXObserver: Notification unsupported: \(name)") pendingRegistrationNames.remove(name) case .notificationAlreadyRegistered: Logger.service.info("AXObserver: Notification already registered: \(name)") pendingRegistrationNames.remove(name) default: Logger.service - .error( + .info( "AXObserver: Unrecognized error \(e) when registering \(name), will try again later" ) } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift index eb1cf7a..f59f5ee 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -110,4 +110,17 @@ public final class BuiltinExtensionConversationServiceProvider< return (try? await conversationService.templates(workspace: workspaceInfo)) } + + public func models() async throws -> [CopilotModel]? { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return nil + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return nil + } + + return (try? await conversationService.models(workspace: workspaceInfo)) + } } diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift index e0bd0d5..f449822 100644 --- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -9,6 +9,7 @@ public protocol ConversationServiceType { func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]? + func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]? } public protocol ConversationServiceProvider { @@ -18,6 +19,7 @@ public protocol ConversationServiceProvider { func rateConversation(turnId: String, rating: ConversationRating) async throws func copyCode(_ request: CopyCodeRequest) async throws func templates() async throws -> [ChatTemplate]? + func models() async throws -> [CopilotModel]? } public struct FileReference: Hashable { @@ -71,6 +73,7 @@ public struct ConversationRequest { public var skills: [String] public var ignoredSkills: [String]? public var references: [FileReference]? + public var model: String? public init( workDoneToken: String, @@ -78,7 +81,8 @@ public struct ConversationRequest { workspaceFolder: String, skills: [String], ignoredSkills: [String]? = nil, - references: [FileReference]? = nil + references: [FileReference]? = nil, + model: String? = nil ) { self.workDoneToken = workDoneToken self.content = content @@ -86,6 +90,7 @@ public struct ConversationRequest { self.skills = skills self.ignoredSkills = ignoredSkills self.references = references + self.model = model } } @@ -201,30 +206,3 @@ public struct ConversationFollowUp: Codable, Equatable { self.type = type } } - -public struct ChatTemplate: Codable, Equatable { - public var id: String - public var description: String - public var shortDescription: String - public var scopes: [ChatPromptTemplateScope] - - public init(id: String, description: String, shortDescription: String, scopes: [ChatPromptTemplateScope]=[]) { - self.id = id - self.description = description - self.shortDescription = shortDescription - self.scopes = scopes - } -} - -public enum ChatPromptTemplateScope: String, Codable, Equatable { - case chatPanel = "chat-panel" - case editor = "editor" - case inline = "inline" -} - -public struct CopilotLanguageServerError: Codable { - public var code: Int? - public var message: String - public var responseIsIncomplete: Bool? - public var responseIsFiltered: Bool? -} diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift new file mode 100644 index 0000000..f6bad68 --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift @@ -0,0 +1,45 @@ + +// MARK: Conversation template +public struct ChatTemplate: Codable, Equatable { + public var id: String + public var description: String + public var shortDescription: String + public var scopes: [PromptTemplateScope] + + public init(id: String, description: String, shortDescription: String, scopes: [PromptTemplateScope]=[]) { + self.id = id + self.description = description + self.shortDescription = shortDescription + self.scopes = scopes + } +} + +public enum PromptTemplateScope: String, Codable, Equatable { + case chatPanel = "chat-panel" + case editPanel = "edit-panel" + case editor = "editor" + case inline = "inline" + case completion = "completion" +} + +public struct CopilotLanguageServerError: Codable { + public var code: Int? + public var message: String + public var responseIsIncomplete: Bool? + public var responseIsFiltered: Bool? +} + +// MARK: Copilot Model +public struct CopilotModel: Codable, Equatable { + public let modelFamily: String + public let modelName: String + public let id: String + public let modelPolicy: CopilotModelPolicy? + public let scopes: [PromptTemplateScope] + public let preview: Bool +} + +public struct CopilotModelPolicy: Codable, Equatable { + public let state: String + public let terms: String +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift index 77d54f2..4409190 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -35,6 +35,7 @@ struct ConversationCreateParams: Codable { var source: ConversationSource? var workspaceFolder: String? var ignoredSkills: [String]? + var model: String? struct Capabilities: Codable { var skills: [String] @@ -134,6 +135,7 @@ struct TurnCreateParams: Codable { var doc: Doc? var ignoredSkills: [String]? var references: [Reference]? + var model: String? } // MARK: Copy @@ -158,25 +160,3 @@ public struct ConversationContextParams: Codable { } public typealias ConversationContextRequest = JSONRPCRequest - -// MARK: Conversation template - -public struct Template: Codable { - public var id: String - public var description: String - public var shortDescription: String - public var scopes: [PromptTemplateScope] - - public init(id: String, description: String, shortDescription: String, scopes: [PromptTemplateScope]) { - self.id = id - self.description = description - self.shortDescription = shortDescription - self.scopes = scopes - } -} - -public enum PromptTemplateScope: String, Codable { - case chatPanel = "chat-panel" - case editor = "editor" - case inline = "inline" -} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift index c13c3e8..300cf4c 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -3,6 +3,7 @@ import JSONRPC import LanguageServerProtocol import Status import SuggestionBasic +import ConversationServiceProvider struct GitHubCopilotDoc: Codable { var source: String @@ -341,13 +342,21 @@ enum GitHubCopilotRequest { // MARK: Conversation templates struct GetTemplates: GitHubCopilotRequestType { - typealias Response = Array