diff --git a/Core/Package.swift b/Core/Package.swift index 10d744d4..5e55ae02 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -200,7 +200,9 @@ let package = Package( .product(name: "OpenAIService", package: "Tool"), .product(name: "AppMonitoring", package: "Tool"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] + ].pro([ + "ProService", + ]) ), .testTarget(name: "PromptToCodeServiceTests", dependencies: ["PromptToCodeService"]), diff --git a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift index d5563b51..9686ca85 100644 --- a/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/SystemInfoChatContextCollector/SystemInfoChatContextCollector.swift @@ -13,7 +13,7 @@ public final class SystemInfoChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], - scopes: Set, + scopes: Set, content: String, configuration: ChatGPTConfiguration ) -> ChatContext { diff --git a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift index 81d1b9fc..29327cea 100644 --- a/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift +++ b/Core/Sources/ChatContextCollectors/WebChatContextCollector/WebChatContextCollector.swift @@ -9,11 +9,11 @@ public final class WebChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], - scopes: Set, + scopes: Set, content: String, configuration: ChatGPTConfiguration ) -> ChatContext { - guard scopes.contains("web") || scopes.contains("w") else { return .empty } + guard scopes.contains(.web) else { return .empty } let links = Self.detectLinks(from: history) + Self.detectLinks(from: content) let functions: [(any ChatGPTFunction)?] = [ SearchFunction(maxTokens: configuration.maxTokens), diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index 72d00503..d5ec598b 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -51,11 +51,13 @@ struct Chat: ReducerProtocol { case observeIsReceivingMessageChange case observeSystemPromptChange case observeExtraSystemPromptChange + case observeDefaultScopesChange case historyChanged case isReceivingMessageChanged case systemPromptChanged case extraSystemPromptChanged + case defaultScopesChanged case chatMenu(ChatMenu.Action) } @@ -68,6 +70,7 @@ struct Chat: ReducerProtocol { case observeIsReceivingMessageChange(UUID) case observeSystemPromptChange(UUID) case observeExtraSystemPromptChange(UUID) + case observeDefaultScopesChange(UUID) } var body: some ReducerProtocol { @@ -131,6 +134,7 @@ struct Chat: ReducerProtocol { await send(.observeIsReceivingMessageChange) await send(.observeSystemPromptChange) await send(.observeExtraSystemPromptChange) + await send(.observeDefaultScopesChange) } case .observeHistoryChange: @@ -198,6 +202,22 @@ struct Chat: ReducerProtocol { } }.cancellable(id: CancelID.observeExtraSystemPromptChange(id), cancelInFlight: true) + case .observeDefaultScopesChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$defaultScopes + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.defaultScopesChanged) + } + }.cancellable(id: CancelID.observeDefaultScopesChange(id), cancelInFlight: true) + case .historyChanged: state.history = service.chatHistory.map { message in .init( @@ -250,6 +270,10 @@ struct Chat: ReducerProtocol { state.chatMenu.extraSystemPrompt = service.extraSystemPrompt return .none + case .defaultScopesChanged: + state.chatMenu.defaultScopes = service.defaultScopes + return .none + case .binding: return .none @@ -266,6 +290,7 @@ struct ChatMenu: ReducerProtocol { var extraSystemPrompt: String = "" var temperatureOverride: Double? = nil var chatModelIdOverride: String? = nil + var defaultScopes: Set = [] } enum Action: Equatable { @@ -274,6 +299,8 @@ struct ChatMenu: ReducerProtocol { case temperatureOverrideSelected(Double?) case chatModelIdOverrideSelected(String?) case customCommandButtonTapped(CustomCommand) + case resetDefaultScopesButtonTapped + case toggleScope(ChatService.Scope) } let service: ChatService @@ -304,6 +331,15 @@ struct ChatMenu: ReducerProtocol { return .run { _ in try await service.handleCustomCommand(command) } + + case .resetDefaultScopesButtonTapped: + return .run { _ in + service.resetDefaultScopes() + } + case let .toggleScope(scope): + return .run { _ in + service.defaultScopes.formSymmetricDifference([scope]) + } } } } diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index bff64f4e..e6a3b2c4 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -1,4 +1,5 @@ import AppKit +import ChatService import ComposableArchitecture import SharedUIComponents import SwiftUI @@ -30,6 +31,7 @@ struct ChatContextMenu: View { chatModel temperature + defaultScopes Divider() @@ -153,6 +155,34 @@ struct ChatContextMenu: View { } } + @ViewBuilder + var defaultScopes: some View { + Menu("Default Scopes") { + WithViewStore(store, observe: \.defaultScopes) { viewStore in + Button(action: { + store.send(.resetDefaultScopesButtonTapped) + }) { + Text("Reset Default Scopes") + } + + Divider() + + ForEach(ChatService.Scope.allCases, id: \.rawValue) { value in + Button(action: { + viewStore.send(.toggleScope(value)) + }) { + HStack { + Text("@" + value.rawValue) + if viewStore.state.contains(value) { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + var customCommandMenu: some View { Menu("Custom Commands") { ForEach( diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index 17babd6d..ffde45a9 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -1,5 +1,7 @@ +import ChatContextCollector import ChatService import ChatTab +import CodableWrappers import Combine import ComposableArchitecture import Foundation @@ -21,6 +23,7 @@ public class ChatGPTChatTab: ChatTab { var configuration: OverridingChatGPTConfiguration.Overriding var systemPrompt: String var extraSystemPrompt: String + var defaultScopes: Set? } struct Builder: ChatTabBuilder { @@ -65,7 +68,8 @@ public class ChatGPTChatTab: ChatTab { history: await service.memory.history, configuration: service.configuration.overriding, systemPrompt: service.systemPrompt, - extraSystemPrompt: service.extraSystemPrompt + extraSystemPrompt: service.extraSystemPrompt, + defaultScopes: service.defaultScopes ) return (try? JSONEncoder().encode(state)) ?? Data() } @@ -79,6 +83,9 @@ public class ChatGPTChatTab: ChatTab { tab.service.configuration.overriding = state.configuration tab.service.mutateSystemPrompt(state.systemPrompt) tab.service.mutateExtraSystemPrompt(state.extraSystemPrompt) + if let scopes = state.defaultScopes { + tab.service.defaultScopes = scopes + } await tab.service.memory.mutateHistory { history in history = state.history } diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index 2b28793c..d6e7dc5b 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import ComposableArchitecture import MarkdownUI import OpenAIService @@ -40,12 +41,14 @@ private struct ListHeightPreferenceKey: PreferenceKey { struct ChatPanelMessages: View { let chat: StoreOf - @State var pinnedToBottom = true + @State var cancellable = Set() + @State var isScrollToBottomButtonDisplayed = true + @State var isPinnedToBottom = true @Namespace var bottomID @Namespace var scrollSpace @State var scrollOffset: Double = 0 @State var listHeight: Double = 0 - @State var isInitialLoad = true + @Environment(\.isEnabled) var isEnabled var body: some View { ScrollViewReader { proxy in @@ -54,7 +57,7 @@ struct ChatPanelMessages: View { Group { Spacer(minLength: 12) - Instruction() + Instruction(chat: chat) ChatHistory(chat: chat) .listItemTint(.clear) @@ -66,24 +69,20 @@ struct ChatPanelMessages: View { } Spacer(minLength: 12) + .id(bottomID) .onAppear { - withAnimation { - proxy.scrollTo(bottomID, anchor: .bottom) - } + proxy.scrollTo(bottomID, anchor: .bottom) + } + .task { + proxy.scrollTo(bottomID, anchor: .bottom) } - .id(bottomID) .background(GeometryReader { geo in let offset = geo.frame(in: .named(scrollSpace)).minY - Color.clear - .preference( - key: ScrollViewOffsetPreferenceKey.self, - value: offset - ) + Color.clear.preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) }) - .preference( - key: ListHeightPreferenceKey.self, - value: listGeo.size.height - ) } .modify { view in if #available(macOS 13.0, *) { @@ -95,6 +94,10 @@ struct ChatPanelMessages: View { } .listStyle(.plain) .coordinateSpace(name: scrollSpace) + .preference( + key: ListHeightPreferenceKey.self, + value: listGeo.size.height + ) .onPreferenceChange(ListHeightPreferenceKey.self) { value in listHeight = value updatePinningState() @@ -110,53 +113,115 @@ struct ChatPanelMessages: View { .opacity(viewStore.state ? 1 : 0) .disabled(!viewStore.state) .transformEffect(.init(translationX: 0, y: viewStore.state ? 0 : 20)) - .animation(.easeInOut(duration: 0.2), value: viewStore.state) } } .overlay(alignment: .bottomTrailing) { - WithViewStore(chat, observe: \.history.last) { viewStore in - Button(action: { - withAnimation(.easeInOut(duration: 0.1)) { - proxy.scrollTo(bottomID, anchor: .bottom) - } - }) { - Image(systemName: "arrow.down") - .padding(4) - .background { - Circle() - .fill(.thickMaterial) - .shadow(color: .black.opacity(0.2), radius: 2) - } - .overlay { - Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) - } - .foregroundStyle(.secondary) - .padding(4) - } - .keyboardShortcut(.downArrow, modifiers: [.command]) - .opacity(pinnedToBottom ? 0 : 1) - .buttonStyle(.plain) - .onChange(of: viewStore.state) { _ in - if pinnedToBottom || isInitialLoad { - if isInitialLoad { - isInitialLoad = false - } - withAnimation { - proxy.scrollTo(bottomID, anchor: .bottom) - } - } - } + scrollToBottomButton(proxy: proxy) + } + .background { + PinToBottomHandler(chat: chat, pinnedToBottom: $isPinnedToBottom) { + proxy.scrollTo(bottomID, anchor: .bottom) } } } } + .onAppear { + trackScrollWheel() + } + .onDisappear { + cancellable.forEach { $0.cancel() } + cancellable = [] + } } + func trackScrollWheel() { + NSApplication.shared.publisher(for: \.currentEvent) + .filter { + if !isEnabled { return false } + return $0?.type == .scrollWheel + } + .compactMap { $0 } + .sink { event in + guard isPinnedToBottom else { return } + let delta = event.deltaY + let scrollUp = delta > 0 + if scrollUp { + isPinnedToBottom = false + } + } + .store(in: &cancellable) + } + + @MainActor func updatePinningState() { - if scrollOffset > listHeight + 24 + 100 || scrollOffset <= 0 { - pinnedToBottom = false - } else { - pinnedToBottom = true + // where does the 32 come from? + withAnimation(.linear(duration: 0.1)) { + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 + || scrollOffset <= 0 + } + } + + @ViewBuilder + func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + Button(action: { + isPinnedToBottom = true + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + }) { + Image(systemName: "arrow.down") + .padding(4) + .background { + Circle() + .fill(.thickMaterial) + .shadow(color: .black.opacity(0.2), radius: 2) + } + .overlay { + Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + .padding(4) + } + .keyboardShortcut(.downArrow, modifiers: [.command]) + .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) + .buttonStyle(.plain) + } + + struct PinToBottomHandler: View { + let chat: StoreOf + @Binding var pinnedToBottom: Bool + let scrollToBottom: () -> Void + + @State var isInitialLoad = true + + struct PinToBottomRelatedState: Equatable { + var isReceivingMessage: Bool + var lastMessage: ChatMessage? + } + + var body: some View { + WithViewStore(chat, observe: { + PinToBottomRelatedState( + isReceivingMessage: $0.isReceivingMessage, + lastMessage: $0.history.last + ) + }) { viewStore in + EmptyView() + .onChange(of: viewStore.state.isReceivingMessage) { isReceiving in + if isReceiving { + pinnedToBottom = true + scrollToBottom() + } + } + .onChange(of: viewStore.state.lastMessage) { _ in + if pinnedToBottom || isInitialLoad { + if isInitialLoad { + isInitialLoad = false + } + scrollToBottom() + } + } + } } } } @@ -225,8 +290,7 @@ private struct StopRespondingButton: View { } private struct Instruction: View { - @AppStorage(\.useCodeScopeByDefaultInChatContext) - var useCodeScopeByDefaultInChatContext + let chat: StoreOf var body: some View { Group { @@ -255,8 +319,9 @@ private struct Instruction: View { | --- | --- | | `@file` | Read the metadata of the editing file | | `@code` | Read the code and metadata in the editing file | - | `@web` (beta) | Search on Bing or query from a web page | + | `@sense`| Experimental. Read the relevant code of the focused editor | | `@project` | Experimental. Access content of the project | + | `@web` (beta) | Search on Bing or query from a web page | To use scopes, you can prefix a message with `@code`. @@ -265,18 +330,24 @@ private struct Instruction: View { ) .modifier(InstructionModifier()) - Markdown( - """ - Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. + WithViewStore(chat, observe: \.chatMenu.defaultScopes) { viewStore in + Markdown( + """ + Hello, I am your AI programming assistant. I can identify issues, explain and even improve code. - \( - useCodeScopeByDefaultInChatContext - ? "Scope **`@code`** is enabled by default." - : "Scope **`@file`** is enabled by default." + \({ + if viewStore.state.isEmpty { + return "No scope is enabled by default" + } else { + let scopes = viewStore.state.map(\.rawValue).sorted() + .joined(separator: ", ") + return "Default scopes: `\(scopes)`" + } + }()) + """ ) - """ - ) - .modifier(InstructionModifier()) + .modifier(InstructionModifier()) + } } } @@ -538,6 +609,7 @@ struct ChatPanelInputArea: View { let availableFeatures = plugins + [ "/exit", "@code", + "@sense", "@project", "@web", ] @@ -612,41 +684,6 @@ struct RoundedCorners: Shape { } } -struct GlobalChatSwitchToggleStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - HStack(spacing: 4) { - Text(configuration.isOn ? "Shared Conversation" : "Local Conversation") - .foregroundStyle(.tertiary) - - RoundedRectangle(cornerRadius: 10, style: .circular) - .foregroundColor(configuration.isOn ? Color.indigo : .gray.opacity(0.5)) - .frame(width: 30, height: 20, alignment: .center) - .overlay( - Circle() - .fill(.regularMaterial) - .padding(.all, 2) - .overlay( - Image( - systemName: configuration - .isOn ? "globe" : "doc.circle" - ) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12, height: 12, alignment: .center) - .foregroundStyle(.secondary) - ) - .offset(x: configuration.isOn ? 5 : -5, y: 0) - .animation(.linear(duration: 0.1), value: configuration.isOn) - ) - .onTapGesture { configuration.isOn.toggle() } - .overlay { - RoundedRectangle(cornerRadius: 10, style: .circular) - .stroke(.black.opacity(0.2), lineWidth: 1) - } - } - } -} - // MARK: - Previews struct ChatPanel_Preview: PreviewProvider { diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 2be2c2f8..30a49b6f 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -6,6 +6,8 @@ import OpenAIService import Preferences public final class ChatService: ObservableObject { + public typealias Scope = ChatContext.Scope + public let memory: ContextAwareAutoManagedChatGPTMemory public let configuration: OverridingChatGPTConfiguration public let chatGPTService: any ChatGPTServiceType @@ -15,6 +17,7 @@ public final class ChatService: ObservableObject { @Published public internal(set) var systemPrompt = UserDefaults.shared .value(for: \.defaultChatSystemPrompt) @Published public internal(set) var extraSystemPrompt = "" + @Published public var defaultScopes = Set() let pluginController: ChatPluginController var cancellable = Set() @@ -37,8 +40,10 @@ public final class ChatService: ObservableObject { public convenience init() { let configuration = UserPreferenceChatGPTConfiguration().overriding() + /// Used by context collector + let extraConfiguration = configuration.overriding() let memory = ContextAwareAutoManagedChatGPTMemory( - configuration: configuration, + configuration: extraConfiguration, functionProvider: ChatFunctionProvider() ) self.init( @@ -46,11 +51,11 @@ public final class ChatService: ObservableObject { configuration: configuration, chatGPTService: ChatGPTService( memory: memory, - configuration: configuration, + configuration: extraConfiguration, functionProvider: memory.functionProvider ) ) - + resetDefaultScopes() memory.chatService = self @@ -60,20 +65,38 @@ public final class ChatService: ObservableObject { } } } - + public func resetDefaultScopes() { - if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { - memory.contextController.defaultScopes = ["code"] - } else { - memory.contextController.defaultScopes = ["file"] + var scopes = Set() + if UserDefaults.shared.value(for: \.enableFileScopeByDefaultInChatContext) { + scopes.insert(.file) + } + + if UserDefaults.shared.value(for: \.enableCodeScopeByDefaultInChatContext) { + scopes.insert(.code) } + + if UserDefaults.shared.value(for: \.enableProjectScopeByDefaultInChatContext) { + scopes.insert(.project) + } + + if UserDefaults.shared.value(for: \.enableSenseScopeByDefaultInChatContext) { + scopes.insert(.sense) + } + + if UserDefaults.shared.value(for: \.enableWebScopeByDefaultInChatContext) { + scopes.insert(.web) + } + + defaultScopes = scopes } public func send(content: String) async throws { + memory.contextController.defaultScopes = defaultScopes guard !isReceivingMessage else { throw CancellationError() } let handledInPlugin = try await pluginController.handleContent(content) if handledInPlugin { return } - + let stream = try await chatGPTService.send(content: content, summary: nil) isReceivingMessage = true do { @@ -115,7 +138,6 @@ public final class ChatService: ObservableObject { public func resetPrompt() async { systemPrompt = UserDefaults.shared.value(for: \.defaultChatSystemPrompt) extraSystemPrompt = "" - resetDefaultScopes() } public func deleteMessage(id: String) async { diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift index 0a22051c..f4600a79 100644 --- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift @@ -14,17 +14,17 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { public var remainingTokens: Int? { get async { await memory.remainingTokens } } - + public var history: [ChatMessage] { get async { await memory.history } } - + func observeHistoryChange(_ observer: @escaping () -> Void) { memory.observeHistoryChange(observer) } init( - configuration: ChatGPTConfiguration, + configuration: OverridingChatGPTConfiguration, functionProvider: ChatFunctionProvider ) { memory = AutoManagedChatGPTMemory( @@ -48,10 +48,13 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory { public func refresh() async { let content = (await memory.history) .last(where: { $0.role == .user || $0.role == .function })?.content - try? await contextController.updatePromptToMatchContent(systemPrompt: """ - \(chatService?.systemPrompt ?? "") - \(chatService?.extraSystemPrompt ?? "") - """, content: content ?? "") + try? await contextController.collectContextInformation( + systemPrompt: """ + \(chatService?.systemPrompt ?? "") + \(chatService?.extraSystemPrompt ?? "") + """, + content: content ?? "" + ) await memory.refresh() } } diff --git a/Core/Sources/ChatService/DynamicContextController.swift b/Core/Sources/ChatService/DynamicContextController.swift index 4757fbec..2f7cf014 100644 --- a/Core/Sources/ChatService/DynamicContextController.swift +++ b/Core/Sources/ChatService/DynamicContextController.swift @@ -8,13 +8,13 @@ final class DynamicContextController { let contextCollectors: [ChatContextCollector] let memory: AutoManagedChatGPTMemory let functionProvider: ChatFunctionProvider - let configuration: ChatGPTConfiguration - var defaultScopes = [] as Set + let configuration: OverridingChatGPTConfiguration + var defaultScopes = [] as Set convenience init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, - configuration: ChatGPTConfiguration, + configuration: OverridingChatGPTConfiguration, contextCollectors: ChatContextCollector... ) { self.init( @@ -28,7 +28,7 @@ final class DynamicContextController { init( memory: AutoManagedChatGPTMemory, functionProvider: ChatFunctionProvider, - configuration: ChatGPTConfiguration, + configuration: OverridingChatGPTConfiguration, contextCollectors: [ChatContextCollector] ) { self.memory = memory @@ -37,10 +37,37 @@ final class DynamicContextController { self.contextCollectors = contextCollectors } - func updatePromptToMatchContent(systemPrompt: String, content: String) async throws { + func collectContextInformation(systemPrompt: String, content: String) async throws { var content = content var scopes = Self.parseScopes(&content) scopes.formUnion(defaultScopes) + + let overridingChatModelId = { + var ids = [String]() + if scopes.contains(.sense) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForSenseScope)) + } + + if scopes.contains(.project) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForProjectScope)) + } + + if scopes.contains(.web) { + ids.append(UserDefaults.shared.value(for: \.preferredChatModelIdForWebScope)) + } + + let chatModels = UserDefaults.shared.value(for: \.chatModels) + let idIndexMap = chatModels.enumerated().reduce(into: [String: Int]()) { + $0[$1.element.id] = $1.offset + } + return ids.sorted(by: { + let lhs = idIndexMap[$0] ?? Int.max + let rhs = idIndexMap[$1] ?? Int.max + return lhs < rhs + }).first + }() + + configuration.overriding.modelId = overridingChatModelId functionProvider.removeAll() let language = UserDefaults.shared.value(for: \.chatGPTLanguage) @@ -65,7 +92,7 @@ final class DynamicContextController { return contexts } - let extraSystemPrompt = contexts + let contextSystemPrompt = contexts .map(\.systemPrompt) .filter { !$0.isEmpty } .joined(separator: "\n\n") @@ -77,16 +104,17 @@ final class DynamicContextController { let contextualSystemPrompt = """ \(language.isEmpty ? "" : "You must always reply in \(language)") - \(systemPrompt)\(extraSystemPrompt.isEmpty ? "" : "\n\(extraSystemPrompt)") + \(systemPrompt) """ await memory.mutateSystemPrompt(contextualSystemPrompt) + await memory.mutateContextSystemPrompt(contextSystemPrompt) await memory.mutateRetrievedContent(contextPrompts.map(\.content)) functionProvider.append(functions: contexts.flatMap(\.functions)) } } extension DynamicContextController { - static func parseScopes(_ prompt: inout String) -> Set { + static func parseScopes(_ prompt: inout String) -> Set { let parser = MessageScopeParser() return parser(&prompt) } diff --git a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift index 7c7b12cb..37a1fd16 100644 --- a/Core/Sources/HostApp/AccountSettings/CodeiumView.swift +++ b/Core/Sources/HostApp/AccountSettings/CodeiumView.swift @@ -1,5 +1,6 @@ import CodeiumService import Foundation +import SharedUIComponents import SwiftUI struct CodeiumView: View { @@ -220,7 +221,7 @@ struct CodeiumView: View { .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) } - Divider() + SettingsDivider("Advanced") Form { Toggle("Verbose Log", isOn: $viewModel.codeiumVerboseLog) diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift index d31b1fb8..1874ddd7 100644 --- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -149,7 +149,7 @@ struct GitHubCopilotView: View { "node" ) ) { - Text("Path to Node (v17+)") + Text("Path to Node (v18+)") } Text( @@ -270,7 +270,7 @@ struct GitHubCopilotView: View { .stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1)) } - Divider() + SettingsDivider("Advanced") Form { Toggle( @@ -287,7 +287,7 @@ struct GitHubCopilotView: View { Toggle("Verbose Log", isOn: $settings.gitHubCopilotVerboseLog) } - Divider() + SettingsDivider("Proxy") Form { TextField( diff --git a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift index 7f903728..ab2879a2 100644 --- a/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/ChatSettingsView.swift @@ -1,6 +1,11 @@ import Preferences +import SharedUIComponents import SwiftUI +#if canImport(ProHostApp) +import ProHostApp +#endif + struct ChatSettingsView: View { class Settings: ObservableObject { static let availableLocalizedLocales = Locale.availableLocalizedLocales @@ -9,10 +14,7 @@ struct ChatSettingsView: View { @AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount @AppStorage(\.chatFontSize) var chatFontSize @AppStorage(\.chatCodeFontSize) var chatCodeFontSize - @AppStorage(\.maxFocusedCodeLineCount) - var maxFocusedCodeLineCount - @AppStorage(\.useCodeScopeByDefaultInChatContext) - var useCodeScopeByDefaultInChatContext + @AppStorage(\.defaultChatFeatureChatModelId) var defaultChatFeatureChatModelId @AppStorage(\.defaultChatSystemPrompt) var defaultChatSystemPrompt @AppStorage(\.chatSearchPluginMaxIterations) var chatSearchPluginMaxIterations @@ -32,12 +34,11 @@ struct ChatSettingsView: View { var body: some View { VStack { chatSettingsForm - Divider() + SettingsDivider("UI") uiForm - Divider() - contextForm - Divider() + SettingsDivider("Plugin") pluginForm + ScopeForm() } } @@ -102,6 +103,7 @@ struct ChatSettingsView: View { "\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))" ) .font(.body) + .foregroundColor(settings.chatGPTTemperature >= 1 ? .red : .secondary) .monospacedDigit() .padding(.vertical, 2) .padding(.horizontal, 6) @@ -160,35 +162,13 @@ struct ChatSettingsView: View { Text("pt") } - + Toggle(isOn: $settings.wrapCodeInCodeBlock) { Text("Wrap code in code block") } } } - @ViewBuilder - var contextForm: some View { - Form { - Toggle(isOn: $settings.useCodeScopeByDefaultInChatContext) { - Text("Use @code scope by default in chat context.") - } - - HStack { - TextField(text: .init(get: { - "\(Int(settings.maxFocusedCodeLineCount))" - }, set: { - settings.maxFocusedCodeLineCount = Int($0) ?? 0 - })) { - Text("Max focused code line count in chat context") - } - .textFieldStyle(.roundedBorder) - - Text("lines") - } - } - } - @ViewBuilder var pluginForm: some View { Form { @@ -235,13 +215,222 @@ struct ChatSettingsView: View { ) } } + + struct ScopeForm: View { + class Settings: ObservableObject { + @AppStorage(\.enableFileScopeByDefaultInChatContext) + var enableFileScopeByDefaultInChatContext: Bool + @AppStorage(\.enableCodeScopeByDefaultInChatContext) + var enableCodeScopeByDefaultInChatContext: Bool + @AppStorage(\.enableSenseScopeByDefaultInChatContext) + var enableSenseScopeByDefaultInChatContext: Bool + @AppStorage(\.enableProjectScopeByDefaultInChatContext) + var enableProjectScopeByDefaultInChatContext: Bool + @AppStorage(\.enableWebScopeByDefaultInChatContext) + var enableWebScopeByDefaultInChatContext: Bool + @AppStorage(\.preferredChatModelIdForSenseScope) + var preferredChatModelIdForSenseScope: String + @AppStorage(\.preferredChatModelIdForProjectScope) + var preferredChatModelIdForProjectScope: String + @AppStorage(\.preferredChatModelIdForWebScope) + var preferredChatModelIdForWebScope: String + @AppStorage(\.chatModels) var chatModels + @AppStorage(\.maxFocusedCodeLineCount) + var maxFocusedCodeLineCount + + init() {} + } + + @StateObject var settings = Settings() + + var body: some View { + VStack { + Scope( + title: Text("File Scope"), + description: "Enable the bot to read the metadata of the editing file." + ) { + Form { + Toggle(isOn: $settings.enableFileScopeByDefaultInChatContext) { + Text("Enable @file scope by default in chat context.") + } + } + } + + Scope( + title: Text("Code Scope"), + description: "Enable the bot to read the code and metadata in the editing file." + ) { + Form { + Toggle(isOn: $settings.enableCodeScopeByDefaultInChatContext) { + Text("Enable @code scope by default in chat context.") + } + + HStack { + TextField(text: .init(get: { + "\(Int(settings.maxFocusedCodeLineCount))" + }, set: { + settings.maxFocusedCodeLineCount = Int($0) ?? 0 + })) { + Text("Max focused code") + } + .textFieldStyle(.roundedBorder) + + Text("lines") + } + } + } + + #if canImport(ProHostApp) + + Scope( + title: WithFeatureEnabled(\.projectScopeInChat) { + Text("Sense Scope (Experimental)") + }, + description: "Experimental. Enable the bot to read the relevant code of the editing file in the project, third party packages and the SDK." + ) { + WithFeatureEnabled(\.projectScopeInChat, alignment: .hidden) { + Form { + Toggle(isOn: $settings.enableSenseScopeByDefaultInChatContext) { + Text("Enable @sense scope by default in chat context.") + } + + Picker( + "Preferred Chat Model", + selection: $settings.preferredChatModelIdForSenseScope + ) { + Text("None").tag("") + + if !settings.chatModels + .contains(where: { + $0.id == settings.preferredChatModelIdForSenseScope + }), + !settings.preferredChatModelIdForSenseScope.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.preferredChatModelIdForSenseScope) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + } + } + } + + Scope( + title: WithFeatureEnabled(\.projectScopeInChat) { + Text("Project Scope (Experimental)") + }, + description: "Experimental. Enable the bot to search code symbols in the project, third party packages and the SDK." + ) { + WithFeatureEnabled(\.projectScopeInChat, alignment: .hidden) { + Form { + Toggle(isOn: $settings.enableProjectScopeByDefaultInChatContext) { + Text("Enable @project scope by default in chat context.") + } + + Picker( + "Preferred Chat Model", + selection: $settings.preferredChatModelIdForProjectScope + ) { + Text("None").tag("") + + if !settings.chatModels + .contains(where: { + $0.id == settings.preferredChatModelIdForProjectScope + }), + !settings.preferredChatModelIdForProjectScope.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.preferredChatModelIdForProjectScope) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + } + } + } + + #endif + + Scope( + title: Text("Web Scope"), + description: "Allow the bot to search on Bing or read a web page." + ) { + Form { + Toggle(isOn: $settings.enableWebScopeByDefaultInChatContext) { + Text("Enable @web scope by default in chat context.") + } + + Picker( + "Preferred Chat Model", + selection: $settings.preferredChatModelIdForWebScope + ) { + Text("None").tag("") + + if !settings.chatModels + .contains(where: { + $0.id == settings.preferredChatModelIdForWebScope + }), + !settings.preferredChatModelIdForWebScope.isEmpty + { + Text( + (settings.chatModels.first?.name).map { "\($0) (Default)" } + ?? "No Model Found" + ) + .tag(settings.preferredChatModelIdForWebScope) + } + + ForEach(settings.chatModels, id: \.id) { chatModel in + Text(chatModel.name).tag(chatModel.id) + } + } + } + } + } + } + + struct Scope: View { + let title: Title + let description: String + let content: () -> Content + + var body: some View { + SettingsDivider(title) + VStack { + Text(description) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(8) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.1)) + } + content() + } + } + } + } } // MARK: - Preview -struct ChatSettingsView_Previews: PreviewProvider { - static var previews: some View { +#Preview { + ScrollView { ChatSettingsView() + .padding() } + .frame(height: 800) + .environment(\.overrideFeatureFlag, \.never) } diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index de42264b..d06fdf7e 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -1,3 +1,4 @@ +import SharedUIComponents import SwiftUI struct PromptToCodeSettingsView: View { @@ -30,7 +31,7 @@ struct PromptToCodeSettingsView: View { selection: $settings.promptToCodeChatModelId ) { Text("Same as Chat Feature").tag("") - + if !settings.chatModels .contains(where: { $0.id == settings.promptToCodeChatModelId }), !settings.promptToCodeChatModelId.isEmpty @@ -52,7 +53,7 @@ struct PromptToCodeSettingsView: View { selection: $settings.promptToCodeEmbeddingModelId ) { Text("Same as Chat Feature").tag("") - + if !settings.embeddingModels .contains(where: { $0.id == settings.promptToCodeEmbeddingModelId }), !settings.promptToCodeEmbeddingModelId.isEmpty @@ -78,16 +79,7 @@ struct PromptToCodeSettingsView: View { } } - Divider() - - Text("Mirroring Settings of Suggestion Feature") - .foregroundColor(.white) - .padding(.vertical, 2) - .padding(.horizontal, 8) - .background( - Color.accentColor, - in: RoundedRectangle(cornerRadius: 20) - ) + SettingsDivider("Mirroring Settings of Suggestion Feature") Form { Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index 8218f95a..7094ac3f 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -1,4 +1,5 @@ import Preferences +import SharedUIComponents import SwiftUI #if canImport(ProHostApp) @@ -27,6 +28,8 @@ struct SuggestionSettingsView: View { var suggestionDisplayCompactMode @AppStorage(\.acceptSuggestionWithTab) var acceptSuggestionWithTab + @AppStorage(\.isSuggestionSenseEnabled) + var isSuggestionSenseEnabled init() {} } @@ -36,112 +39,117 @@ struct SuggestionSettingsView: View { var body: some View { Form { - Group { - Picker(selection: $settings.suggestionPresentationMode) { - ForEach(PresentationMode.allCases, id: \.rawValue) { - switch $0 { - case .nearbyTextCursor: - Text("Nearby Text Cursor").tag($0) - case .floatingWidget: - Text("Floating Widget").tag($0) - } + Picker(selection: $settings.suggestionPresentationMode) { + ForEach(PresentationMode.allCases, id: \.rawValue) { + switch $0 { + case .nearbyTextCursor: + Text("Nearby Text Cursor").tag($0) + case .floatingWidget: + Text("Floating Widget").tag($0) } - } label: { - Text("Presentation") } + } label: { + Text("Presentation") + } - Picker(selection: $settings.suggestionFeatureProvider) { - ForEach(SuggestionFeatureProvider.allCases, id: \.rawValue) { - switch $0 { - case .gitHubCopilot: - Text("GitHub Copilot").tag($0) - case .codeium: - Text("Codeium").tag($0) - } + Picker(selection: $settings.suggestionFeatureProvider) { + ForEach(SuggestionFeatureProvider.allCases, id: \.rawValue) { + switch $0 { + case .gitHubCopilot: + Text("GitHub Copilot").tag($0) + case .codeium: + Text("Codeium").tag($0) } - } label: { - Text("Feature Provider") } + } label: { + Text("Feature Provider") + } - Toggle(isOn: $settings.realtimeSuggestionToggle) { - Text("Real-time Suggestion") - } + Toggle(isOn: $settings.realtimeSuggestionToggle) { + Text("Real-time Suggestion") + } - #if canImport(ProHostApp) - WithFeatureEnabled(\.tabToAcceptSuggestion) { - Toggle(isOn: $settings.acceptSuggestionWithTab) { - Text("Accept Suggestion with Tab") - } + #if canImport(ProHostApp) + WithFeatureEnabled(\.suggestionSense) { + Toggle(isOn: $settings.isSuggestionSenseEnabled) { + Text("Suggestion Cheatsheet (Experimental)") } - #endif + } + #endif - HStack { - Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { - Text("Disable Suggestion Feature Globally") - } + #if canImport(ProHostApp) + WithFeatureEnabled(\.tabToAcceptSuggestion) { + Toggle(isOn: $settings.acceptSuggestionWithTab) { + Text("Accept Suggestion with Tab") + } + } + #endif - Button("Exception List") { - isSuggestionFeatureEnabledListPickerOpen = true - } - }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) { - SuggestionFeatureEnabledProjectListView( - isOpen: $isSuggestionFeatureEnabledListPickerOpen - ) + HStack { + Toggle(isOn: $settings.disableSuggestionFeatureGlobally) { + Text("Disable Suggestion Feature Globally") } - HStack { - Button("Disabled Language List") { - isSuggestionFeatureDisabledLanguageListViewOpen = true - } - }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { - SuggestionFeatureDisabledLanguageListView( - isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen - ) + Button("Exception List") { + isSuggestionFeatureEnabledListPickerOpen = true } + }.sheet(isPresented: $isSuggestionFeatureEnabledListPickerOpen) { + SuggestionFeatureEnabledProjectListView( + isOpen: $isSuggestionFeatureEnabledListPickerOpen + ) + } - HStack { - Slider(value: $settings.realtimeSuggestionDebounce, in: 0...2, step: 0.1) { - Text("Real-time Suggestion Debounce") - } + HStack { + Button("Disabled Language List") { + isSuggestionFeatureDisabledLanguageListViewOpen = true + } + }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { + SuggestionFeatureDisabledLanguageListView( + isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen + ) + } - Text( - "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s" - ) - .font(.body) - .monospacedDigit() - .padding(.vertical, 2) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.primary.opacity(0.1)) - ) + HStack { + Slider(value: $settings.realtimeSuggestionDebounce, in: 0...2, step: 0.1) { + Text("Real-time Suggestion Debounce") } - Divider() + Text( + "\(settings.realtimeSuggestionDebounce.formatted(.number.precision(.fractionLength(2))))s" + ) + .font(.body) + .monospacedDigit() + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.primary.opacity(0.1)) + ) } + } - Group { - Toggle(isOn: $settings.suggestionDisplayCompactMode) { - Text("Hide Buttons") - } + SettingsDivider("UI") - Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { - Text("Hide Common Preceding Spaces") - } + Form { + Toggle(isOn: $settings.suggestionDisplayCompactMode) { + Text("Hide Buttons") + } - HStack { - TextField(text: .init(get: { - "\(Int(settings.suggestionCodeFontSize))" - }, set: { - settings.suggestionCodeFontSize = Double(Int($0) ?? 0) - })) { - Text("Font size of suggestion code") - } - .textFieldStyle(.roundedBorder) + Toggle(isOn: $settings.hideCommonPrecedingSpacesInSuggestion) { + Text("Hide Common Preceding Spaces") + } - Text("pt") + HStack { + TextField(text: .init(get: { + "\(Int(settings.suggestionCodeFontSize))" + }, set: { + settings.suggestionCodeFontSize = Double(Int($0) ?? 0) + })) { + Text("Font size of suggestion code") } - Divider() + .textFieldStyle(.roundedBorder) + + Text("pt") } } } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index c011de4a..90e352ca 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -3,6 +3,7 @@ import ComposableArchitecture import KeyboardShortcuts import LaunchAgentManager import Preferences +import SharedUIComponents import SwiftUI struct GeneralView: View { @@ -12,11 +13,11 @@ struct GeneralView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { AppInfoView() - Divider() + SettingsDivider() ExtensionServiceView(store: store) - Divider() + SettingsDivider() LaunchAgentView() - Divider() + SettingsDivider() GeneralSettingsView() } } diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index 57c48195..b229cf79 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -166,7 +166,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { let secondMessage = """ I will update the code you just provided. - It looks like every line has an indentation of \(indentation) spaces, I will keep that. + Every line has an indentation of \(indentation) spaces, I will keep that. What is your requirement? """ @@ -212,8 +212,14 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { } } } +} + +// MAKR: - Internal - func extractCodeAndDescription(from content: String) -> (code: String, description: String) { +extension OpenAIPromptToCodeService { + func extractCodeAndDescription(from content: String) + -> (code: String, description: String) + { func extractCodeFromMarkdown(_ markdown: String) -> (code: String, endIndex: Int)? { let codeBlockRegex = try! NSRegularExpression( pattern: #"```(?:\w+)?[\n]([\s\S]+?)[\n]```"#, @@ -275,7 +281,8 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { editorInformation: EditorInformation, source: PromptToCodeSource ) -> String { - guard let annotations = editorInformation.editorContent?.lineAnnotations else { return "" } + guard let annotations = editorInformation.editorContent?.lineAnnotations + else { return "" } let all = annotations .lazy .filter { annotation in diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift index 01519d18..1716eb12 100644 --- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -42,11 +42,6 @@ public struct PromptToCodeServiceDependencyKey: DependencyKey { public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() } -public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { - public static let liveValue: () -> PromptToCodeServiceType = { OpenAIPromptToCodeService() } - public static let previewValue: () -> PromptToCodeServiceType = { PreviewPromptToCodeService() } -} - public extension DependencyValues { var promptToCodeService: PromptToCodeServiceType { get { self[PromptToCodeServiceDependencyKey.self] } @@ -59,3 +54,57 @@ public extension DependencyValues { } } +#if canImport(ContextAwarePromptToCodeService) + +import ContextAwarePromptToCodeService + +extension ContextAwarePromptToCodeService: PromptToCodeServiceType { + public func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + try await modifyCode( + code: code, + requirement: requirement, + source: ContextAwarePromptToCodeService.Source( + language: source.language, + documentURL: source.documentURL, + projectRootURL: source.projectRootURL, + allCode: source.allCode, + range: source.range + ), + isDetached: isDetached, + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: generateDescriptionRequirement + ) + } +} + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { + OpenAIPromptToCodeService() + } + + public static let previewValue: () -> PromptToCodeServiceType = { + PreviewPromptToCodeService() + } +} + +#else + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { + OpenAIPromptToCodeService() + } + + public static let previewValue: () -> PromptToCodeServiceType = { + PreviewPromptToCodeService() + } +} + +#endif + diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index b253f89b..a1a80b00 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -36,12 +36,14 @@ struct GUI: ReducerProtocol { get { .init( chatTabInfo: chatTabGroup.tabInfo, - isRestoreFinished: isChatTabRestoreFinished + isRestoreFinished: isChatTabRestoreFinished, + selectedChatTapId: chatTabGroup.selectedTabId ) } set { chatTabGroup.tabInfo = newValue.chatTabInfo isChatTabRestoreFinished = newValue.isRestoreFinished + chatTabGroup.selectedTabId = newValue.selectedChatTapId } } #endif diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 66f1d438..c6c24a86 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -7,16 +7,9 @@ import Workspace import XcodeInspector public final class ScheduledCleaner { - let workspacePool: WorkspacePool - let guiController: GraphicalUserInterfaceController + weak var service: Service? - init( - workspacePool: WorkspacePool, - guiController: GraphicalUserInterfaceController - ) { - self.workspacePool = workspacePool - self.guiController = guiController - } + init() {} func start() { Task { @ServiceActor in @@ -38,6 +31,8 @@ public final class ScheduledCleaner { @ServiceActor func cleanUp() async { + guard let service else { return } + let workspaceInfos = XcodeInspector.shared.xcodes.reduce( into: [ XcodeAppInstanceInspector.WorkspaceIdentifier: @@ -53,18 +48,18 @@ public final class ScheduledCleaner { } } } - for (url, workspace) in workspacePool.workspaces { + for (url, workspace) in service.workspacePool.workspaces { if workspace.isExpired, workspaceInfos[.url(url)] == nil { Logger.service.info("Remove idle workspace") _ = await Task { @MainActor in - guiController.viewStore.send( + service.guiController.viewStore.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( workspace.filespaces.keys ))) ) }.result await workspace.cleanUp(availableTabs: []) - workspacePool.removeWorkspace(url: url) + service.workspacePool.removeWorkspace(url: url) } else { let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) .union(workspaceInfos[.unknown]?.tabs ?? []) @@ -77,7 +72,7 @@ public final class ScheduledCleaner { ) { Logger.service.info("Remove idle filespace") _ = await Task { @MainActor in - guiController.viewStore.send( + service.guiController.viewStore.send( .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) ) }.result @@ -87,11 +82,16 @@ public final class ScheduledCleaner { await workspace.cleanUp(availableTabs: tabs) } } + + #if canImport(ProService) + await service.proService.cleanUp(workspaceInfos: workspaceInfos) + #endif } @ServiceActor public func closeAllChildProcesses() async { - for (_, workspace) in workspacePool.workspaces { + guard let service else { return } + for (_, workspace) in service.workspacePool.workspaces { await workspace.terminateSuggestionService() } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index c5903ad4..0582aed4 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -38,7 +38,7 @@ public final class Service { private init() { @Dependency(\.workspacePool) var workspacePool - scheduledCleaner = .init(workspacePool: workspacePool, guiController: guiController) + scheduledCleaner = .init() workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) } self.workspacePool = workspacePool globalShortcutManager = .init(guiController: guiController) @@ -52,6 +52,8 @@ public final class Service { ProService() } #endif + + scheduledCleaner.service = self } @MainActor @@ -81,9 +83,16 @@ final class GlobalShortcutManager { setupShortcutIfNeeded() KeyboardShortcuts.onKeyUp(for: .showHideWidget) { [guiController] in - guiController.viewStore.send(.suggestionWidget(.circularWidget(.widgetClicked))) + if XcodeInspector.shared.activeXcode == nil, + !guiController.viewStore.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) + { + guiController.viewStore.send(.openChatPanel(forceDetach: true)) + } else { + guiController.viewStore.send(.suggestionWidget(.circularWidget(.widgetClicked))) + } } - + XcodeInspector.shared.$activeApplication.sink { app in if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 955e73fa..12e11915 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -52,56 +52,36 @@ struct ChatWindowView: View { struct ChatTitleBar: View { let store: StoreOf @State var isHovering = false - @Environment(\.controlActiveState) var controlActiveState var body: some View { - HStack(spacing: 4) { - Button(action: { - store.send(.hideButtonClicked) - }) { - Circle() - .fill( - controlActiveState == .key - ? Color(nsColor: .systemOrange) - : Color(nsColor: .disabledControlTextColor) - ) - .frame(width: 10, height: 10) - .shadow(radius: 0.5) - .overlay { - if isHovering { - Image(systemName: "minus") - .resizable() - .foregroundStyle(.black.opacity(0.7)) - .font(Font.title.weight(.heavy)) - .frame(width: 5, height: 1) - } - } + HStack(spacing: 6) { + TrafficLightButton( + isHovering: isHovering, + isActive: true, + color: Color(nsColor: .systemOrange), + action: { + store.send(.hideButtonClicked) + } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 8).weight(.heavy)) } .keyboardShortcut("m", modifiers: [.command]) WithViewStore(store, observe: { $0.chatPanelInASeparateWindow }) { viewStore in - Button(action: { - store.send(.toggleChatPanelDetachedButtonClicked) - }) { - Circle() - .fill( - controlActiveState == .key && viewStore.state - ? Color(nsColor: .systemCyan) - : Color(nsColor: .disabledControlTextColor) - ) - .frame(width: 10, height: 10) - .shadow(radius: 0.5) - .disabled(!viewStore.state) - .overlay { - if isHovering { - Image(systemName: "pin") - .resizable() - .foregroundStyle(.black.opacity(0.7)) - .font(Font.title.weight(.heavy)) - .frame(width: 4, height: 6) - .transformEffect(.init(translationX: 0, y: 0.5)) - } - } + TrafficLightButton( + isHovering: isHovering, + isActive: viewStore.state, + color: Color(nsColor: .systemCyan), + action: { + store.send(.toggleChatPanelDetachedButtonClicked) + } + ) { + Image(systemName: "pin.fill") + .foregroundStyle(.black.opacity(0.5)) + .font(Font.system(size: 6).weight(.black)) + .transformEffect(.init(translationX: 0, y: 0.5)) } } @@ -131,11 +111,47 @@ struct ChatTitleBar: View { .padding(.horizontal, 6) .padding(.top, 1) .frame(maxWidth: .infinity) - .frame(height: 16) + .frame(height: Style.chatWindowTitleBarHeight) .onHover(perform: { hovering in isHovering = hovering }) } + + struct TrafficLightButton: View { + let isHovering: Bool + let isActive: Bool + let color: Color + let action: () -> Void + let icon: () -> Icon + + @Environment(\.controlActiveState) var controlActiveState + + var body: some View { + Button(action: { + action() + }) { + Circle() + .fill( + controlActiveState == .key && isActive + ? color + : Color(nsColor: .separatorColor) + ) + .frame( + width: Style.trafficLightButtonSize, + height: Style.trafficLightButtonSize + ) + .overlay { + Circle().stroke(lineWidth: 0.5).foregroundColor(.black.opacity(0.2)) + } + .overlay { + if isHovering { + icon() + } + } + } + .focusable(false) + } + } } private extension View { @@ -315,29 +331,29 @@ struct ChatTabBarButton: View { icon().foregroundColor(.secondary) content() } - .font(.callout) - .lineLimit(1) - .frame(maxWidth: 120) - .padding(.horizontal, 28) - .contentShape(Rectangle()) - .onTapGesture { - store.send(.tabClicked(id: info.id)) - } - .overlay(alignment: .leading) { - Button(action: { - store.send(.closeTabButtonClicked(id: info.id)) - }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - .padding(2) - .padding(.leading, 8) - .opacity(isHovered ? 1 : 0) + .font(.callout) + .lineLimit(1) + .frame(maxWidth: 120) + .padding(.horizontal, 28) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tabClicked(id: info.id)) + } + .overlay(alignment: .leading) { + Button(action: { + store.send(.closeTabButtonClicked(id: info.id)) + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) } - .onHover { isHovered = $0 } - .animation(.linear(duration: 0.1), value: isHovered) - .animation(.linear(duration: 0.1), value: isSelected) + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) Divider().padding(.vertical, 6) } @@ -377,7 +393,13 @@ struct ChatTabContainer: View { tab.body .opacity(isActive ? 1 : 0) .disabled(!isActive) + .allowsHitTesting(isActive) .frame(maxWidth: .infinity, maxHeight: .infinity) + // move it out of window + .rotationEffect( + isActive ? .zero : .degrees(90), + anchor: .topLeading + ) } else { EmptyView() } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index 47018a0e..a62c5d8a 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -47,7 +47,7 @@ public struct ChatPanelFeature: ReducerProtocol { public struct State: Equatable { public var chatTabGroup = ChatTabGroup() var colorScheme: ColorScheme = .light - var isPanelDisplayed = false + public internal(set) var isPanelDisplayed = false var chatPanelInASeparateWindow = false } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 0680030c..9012535a 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -1,13 +1,15 @@ import ComposableArchitecture +import Environment import Foundation import PromptToCodeService import SuggestionModel -import Environment +import XcodeInspector public struct PromptToCodeGroup: ReducerProtocol { public struct State: Equatable { public var promptToCodes: IdentifiedArrayOf = [] - public var activeDocumentURL: PromptToCode.State.ID? + public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared + .realtimeActiveDocumentURL public var activePromptToCode: PromptToCode.State? { get { if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index bee2092e..7e5510c0 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -331,6 +331,7 @@ public struct WidgetFeature: ReducerProtocol { return .run { send in await send(.observeEditorChange) + await send(.panel(.switchToAnotherEditorAndUpdateContent)) for await notification in notifications { try Task.checkCancellation() @@ -453,6 +454,7 @@ public struct WidgetFeature: ReducerProtocol { state.focusingDocumentURL = xcodeInspector.realtimeActiveDocumentURL return .none + #warning("TODO: use function instead of action for high rate actions like this") case let .updateWindowLocation(animated): guard let widgetLocation = generateWidgetLocation() else { return .none } state.panelState.sharedPanelState.alignTopToAnchor = widgetLocation @@ -522,6 +524,7 @@ public struct WidgetFeature: ReducerProtocol { if shouldDebounce { try await mainQueue.sleep(for: .seconds(0.2)) } + try Task.checkCancellation() let task = Task { @MainActor in if let app = activeApplicationMonitor.activeApplication, app.isXcode { let application = AXUIElementCreateApplication(app.processIdentifier) diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index d520702b..d1e0e102 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -11,6 +11,8 @@ enum Style { static let widgetHeight: Double = 20 static var widgetWidth: Double { widgetHeight } static let widgetPadding: Double = 4 + static let chatWindowTitleBarHeight: Double = 24 + static let trafficLightButtonSize: Double = 12 } extension Color { diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 4918b128..cc252db4 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -256,7 +256,7 @@ extension SuggestionWidgetController: NSWindowDelegate { .frame ?? .zero var mouseLocation = NSEvent.mouseLocation let windowFrame = chatPanelWindow.frame - if mouseLocation.y > windowFrame.maxY - 16, + if mouseLocation.y > windowFrame.maxY - Style.chatWindowTitleBarHeight, mouseLocation.y < windowFrame.maxY, mouseLocation.x > windowFrame.minX, mouseLocation.x < windowFrame.maxX @@ -291,7 +291,7 @@ class ChatWindow: NSWindow { override func mouseDown(with event: NSEvent) { let windowFrame = frame let currentLocation = event.locationInWindow - if currentLocation.y > windowFrame.size.height - 16, + if currentLocation.y > windowFrame.size.height - Style.chatWindowTitleBarHeight, currentLocation.y < windowFrame.size.height, currentLocation.x > 0, currentLocation.x < windowFrame.width diff --git a/Core/Tests/ChatServiceTests/ParseScopesTests.swift b/Core/Tests/ChatServiceTests/ParseScopesTests.swift index 78c6634f..ebfeb83c 100644 --- a/Core/Tests/ChatServiceTests/ParseScopesTests.swift +++ b/Core/Tests/ChatServiceTests/ParseScopesTests.swift @@ -8,14 +8,21 @@ final class ParseScopesTests: XCTestCase { func test_parse_single_scope() async throws { var prompt = "@web hello" let scopes = parse(&prompt) - XCTAssertEqual(scopes, ["web"]) + XCTAssertEqual(scopes, [.web]) + XCTAssertEqual(prompt, "hello") + } + + func test_parse_single_scope_with_prefix() async throws { + var prompt = "@w hello" + let scopes = parse(&prompt) + XCTAssertEqual(scopes, [.web]) XCTAssertEqual(prompt, "hello") } func test_parse_multiple_spaces() async throws { var prompt = "@web hello" let scopes = parse(&prompt) - XCTAssertEqual(scopes, ["web"]) + XCTAssertEqual(scopes, [.web]) XCTAssertEqual(prompt, "hello") } @@ -27,9 +34,9 @@ final class ParseScopesTests: XCTestCase { } func test_parse_multiple_scopes() async throws { - var prompt = "@web+file+selection hello" + var prompt = "@web+file+c+s+project hello" let scopes = parse(&prompt) - XCTAssertEqual(scopes, ["web", "file", "selection"]) + XCTAssertEqual(scopes, [.web, .code, .sense, .project, .file]) XCTAssertEqual(prompt, "hello") } } diff --git a/Pro b/Pro index 5feae55e..ce6f1630 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 5feae55e1e9cb9d60166d9126419bf23c25a995d +Subproject commit ce6f1630793d46564d658d511d423048edc443f3 diff --git a/README.md b/README.md index cf82fa66..cd764b8d 100644 --- a/README.md +++ b/README.md @@ -249,11 +249,13 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`. -| Scope | Description | -| :-----: | ---------------------------------------------------------------------------------------- | -| `@file` | Includes the metadata of the editing document and line annotations in the system prompt. | -| `@code` | Includes the focused/selected code and everything from `@file` in the system prompt. | -| `@web` | Allow the bot to search on Bing or query from a web page | +| Scope | Description | +| :--------: | ---------------------------------------------------------------------------------------- | +| `@file` | Includes the metadata of the editing document and line annotations in the system prompt. | +| `@code` | Includes the focused/selected code and everything from `@file` in the system prompt. | +| `@sense` | Experimental. Read the relevant information of the focused code | +| `@project` | Experimental. Access content of the project | +| `@web` | Allow the bot to search on Bing or query from a web page | `@code` is on by default, if `Use @code scope by default in chat context.` is on. Otherwise, `@file` will be on by default. diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index d132b5dd..03aee079 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -31,6 +31,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var maxTokens: Int @FallbackDecoding public var supportsFunctionCalling: Bool + @FallbackDecoding + public var supportsOpenAIAPI2023_11: Bool @FallbackDecoding public var modelName: String public var azureOpenAIDeploymentName: String { @@ -43,12 +45,14 @@ public struct ChatModel: Codable, Equatable, Identifiable { baseURL: String = "", maxTokens: Int = 4000, supportsFunctionCalling: Bool = true, + supportsOpenAIAPI2023_11: Bool = false, modelName: String = "" ) { self.apiKeyName = apiKeyName self.baseURL = baseURL self.maxTokens = maxTokens self.supportsFunctionCalling = supportsFunctionCalling + self.supportsOpenAIAPI2023_11 = supportsOpenAIAPI2023_11 self.modelName = modelName } } diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift index 5b2d0cc0..eccd7d03 100644 --- a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -3,6 +3,14 @@ import OpenAIService import Parsing public struct ChatContext { + public enum Scope: String, Equatable, CaseIterable, Codable { + case file + case code + case sense + case project + case web + } + public struct RetrievedContent { public var content: String public var priority: Int @@ -31,10 +39,22 @@ public struct ChatContext { } } +public extension ChatContext.Scope { + init?(text: String) { + for scope in Self.allCases { + if scope.rawValue.hasPrefix(text.lowercased()) { + self = scope + return + } + } + return nil + } +} + public protocol ChatContextCollector { func generateContext( history: [ChatMessage], - scopes: Set, + scopes: Set, content: String, configuration: ChatGPTConfiguration ) async -> ChatContext @@ -43,11 +63,11 @@ public protocol ChatContextCollector { public struct MessageScopeParser { public init() {} - public func callAsFunction(_ content: inout String) -> Set { + public func callAsFunction(_ content: inout String) -> Set { return parseScopes(&content) } - func parseScopes(_ prompt: inout String) -> Set { + func parseScopes(_ prompt: inout String) -> Set { guard !prompt.isEmpty else { return [] } do { let parser = Parse { @@ -68,7 +88,7 @@ public struct MessageScopeParser { } let (scopes, rest) = try parser.parse(prompt) prompt = String(rest) - return Set(scopes.map(String.init)) + return Set(scopes.map(String.init).compactMap(ChatContext.Scope.init(text:))) } catch { return [] } diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index d1d90264..d1cdeb3c 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -14,7 +14,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], - scopes: Set, + scopes: Set, content: String, configuration: ChatGPTConfiguration ) -> ChatContext { @@ -22,8 +22,8 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { let context = getActiveDocumentContext(info) activeDocumentContext = context - guard scopes.contains("code") || scopes.contains("c") else { - if scopes.contains("file") || scopes.contains("f") { + guard scopes.contains(.code) else { + if scopes.contains(.file) { var removedCode = context removedCode.focusedContext = nil return .init( @@ -112,7 +112,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { : """ Focused Context: ``` - \(focusedContext.context.joined(separator: "\n")) + \(focusedContext.context.map(\.signature).joined(separator: "\n")) ``` """ diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift index 45cc414c..c7c4d20f 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/LegacyActiveDocumentChatContextCollector.swift @@ -10,7 +10,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { public func generateContext( history: [ChatMessage], - scopes: Set, + scopes: Set, content: String, configuration: ChatGPTConfiguration ) -> ChatContext { @@ -18,7 +18,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { let relativePath = content.relativePath let selectionRange = content.editorContent?.selections.first ?? .outOfScope let editorContent = { - if scopes.contains("file") || scopes.contains("f") { + if scopes.contains(.file) { return """ File Content:```\(content.language.rawValue) \(content.editorContent?.content ?? "") @@ -49,7 +49,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { } } - if UserDefaults.shared.value(for: \.useCodeScopeByDefaultInChatContext) { + if UserDefaults.shared.value(for: \.enableCodeScopeByDefaultInChatContext) { return """ Selected Code \ (start from line \(selectionRange.start.line)):```\(content.language.rawValue) @@ -58,7 +58,7 @@ public struct LegacyActiveDocumentChatContextCollector: ChatContextCollector { """ } - if scopes.contains("selection") || scopes.contains("s") { + if scopes.contains(.code) { return """ Selected Code \ (start from line \(selectionRange.start.line)):```\(content.language.rawValue) diff --git a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift index 27d15ad0..808aeabc 100644 --- a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.2.93" + static let latestSupportedVersion = "1.4.15" public init() {} @@ -62,7 +62,7 @@ public struct CodeiumInstallationManager { continuation.yield(.downloading) let urls = try CodeiumSuggestionService.createFoldersIfNeeded() let urlString = - "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" + "https://github.com/Exafunction/codeium/releases/download/language-server-v\(Self.latestSupportedVersion)/language_server_macos_\(isAppleSilicon() ? "arm" : "x64").gz" guard let url = URL(string: urlString) else { return } // download diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift index 6b4d9525..f10977d4 100644 --- a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -13,7 +13,19 @@ public struct ActiveDocumentContext { public var imports: [String] public struct FocusedContext { - public var context: [String] + public struct Context: Equatable { + public var signature: String + public var name: String + public var range: CursorRange + + public init(signature: String, name: String, range: CursorRange) { + self.signature = signature + self.name = name + self.range = range + } + } + + public var context: [Context] public var contextRange: CursorRange public var codeRange: CursorRange public var code: String @@ -21,7 +33,7 @@ public struct ActiveDocumentContext { public var otherLineAnnotations: [EditorInformation.LineAnnotation] public init( - context: [String], + context: [Context], contextRange: CursorRange, codeRange: CursorRange, code: String, @@ -109,7 +121,7 @@ public struct ActiveDocumentContext { } focusedContext = .init( - context: codeContext.scopeSignatures, + context: codeContext.scopeContexts, contextRange: codeContext.contextRange, codeRange: codeContext.focusedRange, code: codeContext.focusedCode, diff --git a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index a571da5d..ee37adea 100644 --- a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -2,20 +2,22 @@ import Foundation import SuggestionModel public struct CodeContext: Equatable { + public typealias ScopeContext = ActiveDocumentContext.FocusedContext.Context + public enum Scope: Equatable { case file case top - case scope(signature: [String]) + case scope(signature: [ScopeContext]) } - public var scopeSignatures: [String] { + public var scopeContexts: [ScopeContext] { switch scope { case .file: return [] case .top: - return ["Top level of the file"] - case let .scope(signature): - return signature + return [] + case let .scope(contexts): + return contexts } } diff --git a/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift index 53387616..0da2abe7 100644 --- a/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/SwiftFocusedCodeFinder.swift @@ -100,7 +100,7 @@ public struct SwiftFocusedCodeFinder: FocusedCodeFinder { } var contextRange = CursorRange.zero - var signature = [String]() + var signature = [CodeContext.ScopeContext]() while let node = nodes.first { nodes.removeFirst() @@ -114,7 +114,11 @@ public struct SwiftFocusedCodeFinder: FocusedCodeFinder { if let context { contextRange = context.contextRange - signature.insert(context.signature, at: 0) + signature.insert(.init( + signature: context.signature, + name: context.name, + range: context.contextRange + ), at: 0) } if !more { @@ -135,6 +139,7 @@ public struct SwiftFocusedCodeFinder: FocusedCodeFinder { extension SwiftFocusedCodeFinder { struct ContextInfo { var signature: String + var name: String var contextRange: CursorRange var canBeUsedAsCodeRange: Bool = true } @@ -163,6 +168,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -174,6 +180,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -185,6 +192,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -196,6 +204,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: ""), + name: name, contextRange: convertRange(node) ), false) @@ -206,6 +215,7 @@ extension SwiftFocusedCodeFinder { signature: "\(type) \(name)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -217,6 +227,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -228,6 +239,7 @@ extension SwiftFocusedCodeFinder { .prefixedModifiers(node.modifierAndAttributeText(extractText)) .suffixedInheritance(node.inheritanceClauseTexts(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: name, contextRange: convertRange(node) ), false) @@ -242,6 +254,7 @@ extension SwiftFocusedCodeFinder { return (.init( signature: "\(type) \(name)\(signature)" .prefixedModifiers(node.modifierAndAttributeText(extractText)), + name: name, contextRange: convertRange(node) ), true) @@ -254,7 +267,9 @@ extension SwiftFocusedCodeFinder { signature: "\(type) \(name)\(signature.isEmpty ? "" : "\(signature)")" .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), - contextRange: convertRange(node) + name: name, + contextRange: convertRange(node), + canBeUsedAsCodeRange: false ), true) case let node as AccessorDeclSyntax: @@ -265,6 +280,7 @@ extension SwiftFocusedCodeFinder { signature: signature .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: keyword, contextRange: convertRange(node) ), true) @@ -278,6 +294,7 @@ extension SwiftFocusedCodeFinder { signature: signature .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), + name: "subscript", contextRange: convertRange(node) ), true) @@ -288,7 +305,7 @@ extension SwiftFocusedCodeFinder { signature: "\(signature)" .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), - + name: "init", contextRange: convertRange(node) ), true) @@ -299,7 +316,7 @@ extension SwiftFocusedCodeFinder { signature: signature .prefixedModifiers(node.modifierAndAttributeText(extractText)) .replacingOccurrences(of: "\n", with: " "), - + name: "deinit", contextRange: convertRange(node) ), true) @@ -308,6 +325,7 @@ extension SwiftFocusedCodeFinder { return (.init( signature: signature.replacingOccurrences(of: "\n", with: " "), + name: "closure", contextRange: convertRange(node) ), true) @@ -316,6 +334,7 @@ extension SwiftFocusedCodeFinder { return (.init( signature: signature.replacingOccurrences(of: "\n", with: " "), + name: "function call", contextRange: convertRange(node), canBeUsedAsCodeRange: false ), true) @@ -323,6 +342,7 @@ extension SwiftFocusedCodeFinder { case let node as SwitchCaseSyntax: return (.init( signature: node.trimmedDescription.replacingOccurrences(of: "\n", with: " "), + name: "switch", contextRange: convertRange(node) ), true) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift index f2837db6..051a3425 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift @@ -10,7 +10,7 @@ public struct GitHubCopilotInstallationManager { return URL(string: link)! } - static let latestSupportedVersion = "1.10.3" + static let latestSupportedVersion = "1.11.4" public init() {} diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index f4d97d1e..8b740ddc 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -19,6 +19,7 @@ public final class Logger { public static let gitHubCopilot = Logger(category: "GitHubCopilot") public static let codeium = Logger(category: "Codeium") public static let langchain = Logger(category: "LangChain") + public static let retrieval = Logger(category: "Retrieval") #if DEBUG /// Use a temp logger to log something temporary. I won't be available in release builds. public static let temp = Logger(category: "Temp") diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index a1ef374f..640bf73a 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -212,6 +212,8 @@ extension ChatGPTService { /// Send the memory as prompt to ChatGPT, with stream enabled. func sendMemory() async throws -> AsyncThrowingStream { + await memory.refresh() + guard let model = configuration.model else { throw ChatGPTServiceError.chatModelNotAvailable } @@ -219,8 +221,6 @@ extension ChatGPTService { throw ChatGPTServiceError.endpointIncorrect } - await memory.refresh() - let messages = await memory.messages.map { CompletionRequestBody.Message( role: $0.role, @@ -325,6 +325,8 @@ extension ChatGPTService { /// Send the memory as prompt to ChatGPT, with stream disabled. func sendMemoryAndWait() async throws -> ChatMessage? { + await memory.refresh() + guard let model = configuration.model else { throw ChatGPTServiceError.chatModelNotAvailable } @@ -332,8 +334,6 @@ extension ChatGPTService { throw ChatGPTServiceError.endpointIncorrect } - await memory.refresh() - let messages = await memory.messages.map { CompletionRequestBody.Message( role: $0.role, diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index 81fc28a1..54b7fbf2 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -1,24 +1,25 @@ import AIModel import Foundation +import Keychain import Preferences public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public var chatModelKey: KeyPath>? - + public var temperature: Double { min(max(0, UserDefaults.shared.value(for: \.chatGPTTemperature)), 2) } public var model: ChatModel? { let models = UserDefaults.shared.value(for: \.chatModels) - + if let chatModelKey { let id = UserDefaults.shared.value(for: chatModelKey) if let model = models.first(where: { $0.id == id }) { return model } } - + let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId) return models.first { $0.id == id } ?? models.first @@ -54,6 +55,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public var maxTokens: Int? public var minimumReplyTokens: Int? public var runFunctionsAutomatically: Bool? + public var apiKey: String? public init( temperature: Double? = nil, @@ -62,7 +64,8 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { stop: [String]? = nil, maxTokens: Int? = nil, minimumReplyTokens: Int? = nil, - runFunctionsAutomatically: Bool? = nil + runFunctionsAutomatically: Bool? = nil, + apiKey: String? = nil ) { self.temperature = temperature self.modelId = modelId @@ -71,6 +74,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { self.maxTokens = maxTokens self.minimumReplyTokens = minimumReplyTokens self.runFunctionsAutomatically = runFunctionsAutomatically + self.apiKey = apiKey } } @@ -103,15 +107,24 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { } public var maxTokens: Int { - overriding.maxTokens ?? configuration.maxTokens + if let maxTokens = overriding.maxTokens { return maxTokens } + if let model { return model.info.maxTokens } + return configuration.maxTokens } public var minimumReplyTokens: Int { - overriding.minimumReplyTokens ?? configuration.minimumReplyTokens + if let minimumReplyTokens = overriding.minimumReplyTokens { return minimumReplyTokens } + return maxTokens / 5 } public var runFunctionsAutomatically: Bool { overriding.runFunctionsAutomatically ?? configuration.runFunctionsAutomatically } + + public var apiKey: String { + if let apiKey = overriding.apiKey { return apiKey } + guard let name = model?.info.apiKeyName else { return configuration.apiKey } + return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey + } } diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift index 9a117d63..0a4f36e6 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift @@ -9,6 +9,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { public private(set) var remainingTokens: Int? public var systemPrompt: String + public var contextSystemPrompt: String public var retrievedContent: [String] = [] public var history: [ChatMessage] = [] { didSet { onHistoryChange() } @@ -27,6 +28,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { functionProvider: ChatGPTFunctionProvider ) { self.systemPrompt = systemPrompt + contextSystemPrompt = "" self.configuration = configuration self.functionProvider = functionProvider _ = Self.encoder // force pre-initialize @@ -40,6 +42,10 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { systemPrompt = newPrompt } + public func mutateContextSystemPrompt(_ newPrompt: String) { + contextSystemPrompt = newPrompt + } + public func mutateRetrievedContent(_ newContent: [String]) { retrievedContent = newContent } @@ -67,6 +73,8 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { /// [Retrieved Content B] /// [Functions] priority: high /// [Message History] priority: medium + /// [Context System Prompt] priority: high + /// [Latest Message] priority: high /// ``` func generateSendingHistory( maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount), @@ -80,7 +88,12 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { } var smallestSystemPromptMessage = ChatMessage(role: .system, content: systemPrompt) + var contextSystemPromptMessage = ChatMessage(role: .system, content: contextSystemPrompt) let smallestSystemMessageTokenCount = countToken(&smallestSystemPromptMessage) + let contextSystemPromptTokenCount = !contextSystemPrompt.isEmpty + ? countToken(&contextSystemPromptMessage) + : 0 + let functionTokenCount = functionProvider.functions.reduce(into: 0) { partial, function in var count = encoder.countToken(text: function.name) + encoder.countToken(text: function.description) @@ -92,6 +105,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { partial += count } let mandatoryContentTokensCount = smallestSystemMessageTokenCount + + contextSystemPromptTokenCount + functionTokenCount + 3 // every reply is primed with <|start|>assistant<|message|> @@ -135,13 +149,13 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { for (index, content) in retrievedContent.filter({ !$0.isEmpty }).enumerated() { if index == 0 { if !appendToSystemPrompt(""" - - + + ## Relevant Content - + Below are information related to the conversation, separated by \(separator) - + """) { break } } else { if !appendToSystemPrompt("\n\(separator)\n") { break } @@ -154,16 +168,22 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory { let message = ChatMessage(role: .system, content: systemPrompt) allMessages.append(message) } + + if !contextSystemPrompt.isEmpty { + allMessages.insert(contextSystemPromptMessage, at: 1) + } #if DEBUG Logger.service.info(""" Sending tokens count - system prompt: \(smallestSystemMessageTokenCount) + - context system prompt: \(contextSystemPromptTokenCount) - functions: \(functionTokenCount) - messages: \(messageTokenCount) - retrieved content: \(retrievedContentTokenCount) - total: \( smallestSystemMessageTokenCount + + contextSystemPromptTokenCount + functionTokenCount + messageTokenCount + retrievedContentTokenCount diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index dd7e3f24..7f49a153 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -88,7 +88,7 @@ public struct UserDefaultPreferenceKeys { defaultValue: false, key: "HideCircularWidget" ) - + public let showHideWidgetShortcutGlobally = PreferenceKey( defaultValue: false, key: "ShowHideWidgetShortcutGlobally" @@ -269,11 +269,11 @@ public extension UserDefaultPreferenceKeys { var promptToCodeGenerateDescriptionInUserPreferredLanguage: PreferenceKey { .init(defaultValue: true, key: "PromptToCodeGenerateDescriptionInUserPreferredLanguage") } - + var promptToCodeChatModelId: PreferenceKey { .init(defaultValue: "", key: "PromptToCodeChatModelId") } - + var promptToCodeEmbeddingModelId: PreferenceKey { .init(defaultValue: "", key: "PromptToCodeEmbeddingModelId") } @@ -325,6 +325,10 @@ public extension UserDefaultPreferenceKeys { var acceptSuggestionWithTab: PreferenceKey { .init(defaultValue: true, key: "AcceptSuggestionWithTab") } + + var isSuggestionSenseEnabled: PreferenceKey { + .init(defaultValue: false, key: "IsSuggestionSenseEnabled") + } } // MARK: - Chat @@ -366,7 +370,7 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: 100, key: "MaxEmbeddableFileInChatContextLineCount") } - var useCodeScopeByDefaultInChatContext: PreferenceKey { + var useCodeScopeByDefaultInChatContext: DeprecatedPreferenceKey { .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") } @@ -389,10 +393,42 @@ public extension UserDefaultPreferenceKeys { var chatSearchPluginMaxIterations: PreferenceKey { .init(defaultValue: 3, key: "ChatSearchPluginMaxIterations") } - + var wrapCodeInChatCodeBlock: PreferenceKey { .init(defaultValue: true, key: "WrapCodeInChatCodeBlock") } + + var enableFileScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: true, key: "EnableFileScopeByDefaultInChatContext") + } + + var enableCodeScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") + } + + var enableSenseScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: false, key: "EnableSenseScopeByDefaultInChatContext") + } + + var enableProjectScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: false, key: "EnableProjectScopeByDefaultInChatContext") + } + + var enableWebScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: false, key: "EnableWebScopeByDefaultInChatContext") + } + + var preferredChatModelIdForSenseScope: PreferenceKey { + .init(defaultValue: "", key: "PreferredChatModelIdForSenseScope") + } + + var preferredChatModelIdForProjectScope: PreferenceKey { + .init(defaultValue: "", key: "PreferredChatModelIdForProjectScope") + } + + var preferredChatModelIdForWebScope: PreferenceKey { + .init(defaultValue: "", key: "PreferredChatModelIdForWebScope") + } } // MARK: - Bing Search @@ -508,7 +544,7 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" ) } - + var disableEnhancedWorkspace: FeatureFlag { .init( defaultValue: false, diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 3f454240..9531b55d 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -7,8 +7,11 @@ public enum ChatGPTModel: String { case gpt432k = "gpt-4-32k" case gpt40314 = "gpt-4-0314" case gpt40613 = "gpt-4-0613" + case gpt41106Preview = "gpt-4-1106-preview" + case gpt4VisionPreview = "gpt-4-vision-preview" case gpt35Turbo0301 = "gpt-3.5-turbo-0301" case gpt35Turbo0613 = "gpt-3.5-turbo-0613" + case gpt35Turbo1106 = "gpt-3.5-turbo-1106" case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613" case gpt432k0314 = "gpt-4-32k-0314" case gpt432k0613 = "gpt-4-32k-0613" @@ -31,14 +34,29 @@ public extension ChatGPTModel { return 4096 case .gpt35Turbo0613: return 4096 + case .gpt35Turbo1106: + return 16385 case .gpt35Turbo16k: - return 16384 + return 16385 case .gpt35Turbo16k0613: - return 16384 + return 16385 case .gpt40613: return 8192 case .gpt432k0613: return 32768 + case .gpt41106Preview: + return 128000 + case .gpt4VisionPreview: + return 128000 + } + } + + var supportsImages: Bool { + switch self { + case .gpt4VisionPreview: + return true + default: + return false } } } diff --git a/Tool/Sources/SharedUIComponents/SettingsDivider.swift b/Tool/Sources/SharedUIComponents/SettingsDivider.swift new file mode 100644 index 00000000..15db820d --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SettingsDivider.swift @@ -0,0 +1,42 @@ +import SwiftUI + +public struct SettingsDivider: View { + let title: Title? + + public init(_ title: Title) { + self.title = title + } + + public var body: some View { + if let title { + HStack { + VStack { + Divider() + } + title + .foregroundStyle(.secondary) + .font(.subheadline) + .zIndex(2) + VStack { + Divider() + } + } + .padding(.vertical, 8) + } else { + Divider() + .padding(.vertical, 8) + } + } +} + +extension SettingsDivider where Title == Text { + public init(_ title: String) { + self.title = Text(title) + } +} + +extension SettingsDivider where Title == EmptyView { + public init() { + self.title = nil + } +} diff --git a/Tool/Sources/SuggestionModel/EditorInformation.swift b/Tool/Sources/SuggestionModel/EditorInformation.swift index c0964f8d..21b73cd0 100644 --- a/Tool/Sources/SuggestionModel/EditorInformation.swift +++ b/Tool/Sources/SuggestionModel/EditorInformation.swift @@ -102,12 +102,12 @@ public struct EditorInformation { } var content = rangeLines if !content.isEmpty { + let dropLastCount = max(0, content[content.endIndex - 1].count - range.end.character) content[content.endIndex - 1] = String( - content[content.endIndex - 1].dropLast( - content[content.endIndex - 1].count - range.end.character - ) + content[content.endIndex - 1].dropLast(dropLastCount) ) - content[0] = String(content[0].dropFirst(range.start.character)) + let dropFirstCount = max(0, range.start.character) + content[0] = String(content[0].dropFirst(dropFirstCount)) } return (content.joined(), rangeLines) } diff --git a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift index 9d7d364b..6bad79d5 100644 --- a/Tool/Sources/SuggestionModel/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionModel/ExportedFromLSP.swift @@ -1,5 +1,6 @@ import LanguageServerProtocol +/// Line starts at 0. public typealias CursorPosition = LanguageServerProtocol.Position public extension CursorPosition { diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index c218f021..cd3f54fd 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -141,6 +141,8 @@ public final class XcodeInspector: ObservableObject { } } + #warning("TODO: Double check before releasing 0.27.0") + @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { activeApplication = xcode diff --git a/Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift b/Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift index c145c5f6..88355c9c 100644 --- a/Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift +++ b/Tool/Tests/ActiveDocumentChatContextCollectorTests/SwiftFocusedCodeFinderTests.swift @@ -355,7 +355,7 @@ final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { XCTAssertEqual(context, .init( scope: .top, contextRange: .init(startPair: (0, 0), endPair: (13, 2)), - focusedRange: .init(startPair: (0, 0), endPair: (10, 15)), + focusedRange: .init(startPair: (0, 0), endPair: (13, 2)), focusedCode: """ @MainActor public @@ -368,6 +368,9 @@ final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { } func hello() { + print("hello") + print("hello") + } """, imports: [] diff --git a/Version.xcconfig b/Version.xcconfig index b0ca9172..7664fb23 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.26.0 -APP_BUILD = 272 +APP_VERSION = 0.27.0 +APP_BUILD = 280 diff --git a/appcast.xml b/appcast.xml index 07f4f9e8..19c2b094 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.27.0 + Fri, 10 Nov 2023 02:34:25 +0800 + 280 + 0.27.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.27.0 + + + + 0.26.0 Sun, 22 Oct 2023 18:58:49 +0800