diff --git a/Core/Sources/ChatGPTChatTab/Chat.swift b/Core/Sources/ChatGPTChatTab/Chat.swift index d5ec598b..9bfbe68b 100644 --- a/Core/Sources/ChatGPTChatTab/Chat.swift +++ b/Core/Sources/ChatGPTChatTab/Chat.swift @@ -32,6 +32,11 @@ struct Chat: ReducerProtocol { var history: [ChatMessage] = [] @BindingState var isReceivingMessage = false var chatMenu = ChatMenu.State() + @BindingState var focusedField: Field? + + enum Field: String, Hashable { + case textField + } } enum Action: Equatable, BindableAction { @@ -45,6 +50,7 @@ struct Chat: ReducerProtocol { case deleteMessageButtonTapped(MessageID) case resendMessageButtonTapped(MessageID) case setAsExtraPromptButtonTapped(MessageID) + case focusOnTextField case observeChatService case observeHistoryChange @@ -89,6 +95,7 @@ struct Chat: ReducerProtocol { await send(.isReceivingMessageChanged) await send(.systemPromptChanged) await send(.extraSystemPromptChanged) + await send(.focusOnTextField) } case .sendButtonTapped: @@ -127,6 +134,10 @@ struct Chat: ReducerProtocol { return .run { _ in await service.setMessageAsExtraPrompt(id: id) } + + case .focusOnTextField: + state.focusedField = .textField + return .none case .observeChatService: return .run { send in diff --git a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift index ffde45a9..4d5f0a38 100644 --- a/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift +++ b/Core/Sources/ChatGPTChatTab/ChatGPTChatTab.swift @@ -116,13 +116,19 @@ public class ChatGPTChatTab: ChatTab { public func start() { chatTabViewStore.send(.updateTitle("Chat")) - service.$systemPrompt.removeDuplicates().sink { _ in + chatTabViewStore.publisher.focusTrigger.removeDuplicates().sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.viewStore.send(.focusOnTextField) + } + }.store(in: &cancellable) + + service.$systemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in self?.chatTabViewStore.send(.tabContentUpdated) } }.store(in: &cancellable) - service.$extraSystemPrompt.removeDuplicates().sink { _ in + service.$extraSystemPrompt.removeDuplicates().sink { [weak self] _ in Task { @MainActor [weak self] in self?.chatTabViewStore.send(.tabContentUpdated) } @@ -134,12 +140,11 @@ public class ChatGPTChatTab: ChatTab { } }.store(in: &cancellable) - viewStore.publisher.removeDuplicates() - .sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.chatTabViewStore.send(.tabContentUpdated) - } - }.store(in: &cancellable) + viewStore.publisher.removeDuplicates().sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.chatTabViewStore.send(.tabContentUpdated) + } + }.store(in: &cancellable) } } diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift index d6e7dc5b..af42e170 100644 --- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift +++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift @@ -498,16 +498,13 @@ struct FunctionMessage: View { struct ChatPanelInputArea: View { let chat: StoreOf - @FocusState var isInputAreaFocused: Bool + @FocusState var focusedField: Chat.State.Field? var body: some View { HStack { clearButton textEditor } - .onAppear { - isInputAreaFocused = true - } .padding(8) .background(.ultraThickMaterial) } @@ -538,8 +535,11 @@ struct ChatPanelInputArea: View { @MainActor var textEditor: some View { HStack(spacing: 0) { - WithViewStore(chat, removeDuplicates: { $0.typedMessage == $1.typedMessage }) { - viewStore in + WithViewStore( + chat, + removeDuplicates: { + $0.typedMessage == $1.typedMessage && $0.focusedField == $1.focusedField + }) { viewStore in ZStack(alignment: .center) { // a hack to support dynamic height of TextEditor Text( @@ -560,7 +560,8 @@ struct ChatPanelInputArea: View { .padding(.top, 1) .padding(.bottom, -1) } - .focused($isInputAreaFocused) + .focused($focusedField, equals: .textField) + .bind(viewStore.$focusedField, to: $focusedField) .padding(8) .fixedSize(horizontal: false, vertical: true) } @@ -595,7 +596,7 @@ struct ChatPanelInputArea: View { .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) Button(action: { - isInputAreaFocused = true + focusedField = .textField }) { EmptyView() } diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index 30a49b6f..2cbaee67 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -42,6 +42,10 @@ public final class ChatService: ObservableObject { let configuration = UserPreferenceChatGPTConfiguration().overriding() /// Used by context collector let extraConfiguration = configuration.overriding() + extraConfiguration.textWindowTerminator = { + guard let last = $0.last else { return false } + return last.isNewline || last.isPunctuation + } let memory = ContextAwareAutoManagedChatGPTMemory( configuration: extraConfiguration, functionProvider: ChatFunctionProvider() diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift index a1a80b00..9f402411 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -1,4 +1,5 @@ import ActiveApplicationMonitor +import AppActivator import AppKit import ChatGPTChatTab import ChatTab @@ -6,6 +7,7 @@ import ComposableArchitecture import Dependencies import Environment import Preferences +import SuggestionModel import SuggestionWidget #if canImport(ProChatTabs) @@ -54,6 +56,7 @@ struct GUI: ReducerProtocol { case openChatPanel(forceDetach: Bool) case createChatGPTChatTabIfNeeded case sendCustomCommandToActiveChat(CustomCommand) + case toggleWidgetsHotkeyPressed case suggestionWidget(WidgetFeature.Action) @@ -66,7 +69,8 @@ struct GUI: ReducerProtocol { #endif } - @Dependency(\.chatTabPool) var chatTabPool: ChatTabPool + @Dependency(\.chatTabPool) var chatTabPool + @Dependency(\.activateThisApp) var activateThisApp public enum Debounce: Hashable { case updateChatTabOrder @@ -135,6 +139,9 @@ struct GUI: ReducerProtocol { .chatPanel(.presentChatPanel(forceDetach: forceDetach)) ) ) + await send(.suggestionWidget(.updateKeyWindow(.chatPanel))) + + activateThisApp() } case .createChatGPTChatTabIfNeeded: @@ -192,6 +199,11 @@ struct GUI: ReducerProtocol { } } + case .toggleWidgetsHotkeyPressed: + return .run { send in + await send(.suggestionWidget(.circularWidget(.widgetClicked))) + } + case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): #if canImport(ChatTabPersistent) // when a tab is updated, persist it. @@ -262,12 +274,10 @@ public final class GraphicalUserInterfaceController { Task { let handler = PseudoCommandHandler() await handler.acceptPromptToCode() - if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode, - !promptToCode.isContinuous - { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() + if !promptToCode.isContinuous { + NSWorkspace.activatePreviousActiveXcode() + } else { + NSWorkspace.activateThisApp() } } } diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift index 8b449020..7fa244e8 100644 --- a/Core/Sources/Service/GUI/WidgetDataSource.swift +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -1,4 +1,6 @@ import ActiveApplicationMonitor +import AppActivator +import AppKit import ChatService import ComposableArchitecture import Foundation @@ -39,24 +41,14 @@ extension WidgetDataSource: SuggestionWidgetDataSource { Task { let handler = PseudoCommandHandler() await handler.rejectSuggestions() - if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode - { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } + NSWorkspace.activatePreviousActiveXcode() } }, onAcceptSuggestionTapped: { Task { let handler = PseudoCommandHandler() await handler.acceptSuggestion() - if let app = ActiveApplicationMonitor.shared.previousApp, - app.isXcode - { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } + NSWorkspace.activatePreviousActiveXcode() } } ) diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift new file mode 100644 index 00000000..3ed6a69c --- /dev/null +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -0,0 +1,63 @@ +import AppKit +import Combine +import Foundation +import KeyboardShortcuts +import XcodeInspector + +extension KeyboardShortcuts.Name { + static let showHideWidget = Self("ShowHideWidget") +} + +@MainActor +final class GlobalShortcutManager { + let guiController: GraphicalUserInterfaceController + private var cancellable = Set() + + nonisolated init(guiController: GraphicalUserInterfaceController) { + self.guiController = guiController + } + + func start() { + KeyboardShortcuts.userDefaults = .shared + setupShortcutIfNeeded() + + KeyboardShortcuts.onKeyUp(for: .showHideWidget) { [guiController] in + let isXCodeActive = XcodeInspector.shared.activeXcode != nil + + if !isXCodeActive, + !guiController.viewStore.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) + { + guiController.viewStore.send(.openChatPanel(forceDetach: true)) + } else { + guiController.viewStore.send(.toggleWidgetsHotkeyPressed) + } + } + + XcodeInspector.shared.$activeApplication.sink { app in + if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { + let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { + true + } else { + false + } + if shouldBeEnabled { + self.setupShortcutIfNeeded() + } else { + self.removeShortcutIfNeeded() + } + } else { + self.setupShortcutIfNeeded() + } + }.store(in: &cancellable) + } + + func setupShortcutIfNeeded() { + KeyboardShortcuts.enable(.showHideWidget) + } + + func removeShortcutIfNeeded() { + KeyboardShortcuts.disable(.showHideWidget) + } +} + diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index 0582aed4..4702ee68 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,7 +1,5 @@ -import Combine import Dependencies import Foundation -import KeyboardShortcuts import Workspace import WorkspaceSuggestionService import XcodeInspector @@ -15,10 +13,6 @@ import ProService public static let shared = TheActor() } -extension KeyboardShortcuts.Name { - static let showHideWidget = Self("ShowHideWidget") -} - /// The running extension service. public final class Service { public static let shared = Service() @@ -69,54 +63,3 @@ public final class Service { } } -@MainActor -final class GlobalShortcutManager { - let guiController: GraphicalUserInterfaceController - private var cancellable = Set() - - nonisolated init(guiController: GraphicalUserInterfaceController) { - self.guiController = guiController - } - - func start() { - KeyboardShortcuts.userDefaults = .shared - setupShortcutIfNeeded() - - KeyboardShortcuts.onKeyUp(for: .showHideWidget) { [guiController] in - 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 { - true - } else { - false - } - if shouldBeEnabled { - self.setupShortcutIfNeeded() - } else { - self.removeShortcutIfNeeded() - } - } else { - self.setupShortcutIfNeeded() - } - }.store(in: &cancellable) - } - - func setupShortcutIfNeeded() { - KeyboardShortcuts.enable(.showHideWidget) - } - - func removeShortcutIfNeeded() { - KeyboardShortcuts.disable(.showHideWidget) - } -} - diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index d4668628..a2fde81e 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -1,3 +1,4 @@ +import AppKit import ChatService import Environment import Foundation @@ -409,7 +410,7 @@ extension WindowBaseCommandHandler { }() as (String, CursorRange) let viewStore = Service.shared.guiController.viewStore - + let customCommandTemplateProcessor = CustomCommandTemplateProcessor() let newExtraSystemPrompt = extraSystemPrompt.map(customCommandTemplateProcessor.process) let newPrompt = prompt.map(customCommandTemplateProcessor.process) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index a62c5d8a..84446c9d 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -70,14 +70,15 @@ public struct ChatPanelFeature: ReducerProtocol { case switchToNextTab case switchToPreviousTab case moveChatTab(from: Int, to: Int) + case focusActiveChatTab case chatTab(id: String, action: ChatTabItem.Action) } @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @Dependency(\.xcodeInspector) var xcodeInspector - @Dependency(\.activatePreviouslyActiveXcode) var activatePreviouslyActiveXcode - @Dependency(\.activateExtensionService) var activateExtensionService + @Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode + @Dependency(\.activateThisApp) var activateExtensionService @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection public var body: some ReducerProtocol { @@ -87,7 +88,7 @@ public struct ChatPanelFeature: ReducerProtocol { state.isPanelDisplayed = false return .run { _ in - await activatePreviouslyActiveXcode() + activatePreviouslyActiveXcode() } case .closeActiveTabClicked: @@ -117,8 +118,9 @@ public struct ChatPanelFeature: ReducerProtocol { state.chatPanelInASeparateWindow = true } state.isPanelDisplayed = true - return .run { _ in - await activateExtensionService() + return .run { send in + activateExtensionService() + await send(.focusActiveChatTab) } case let .updateChatTabInfo(chatTabInfo): @@ -172,14 +174,18 @@ public struct ChatPanelFeature: ReducerProtocol { return .none } state.chatTabGroup.selectedTabId = id - return .none + return .run { send in + await send(.focusActiveChatTab) + } case let .appendAndSelectTab(tab): guard !state.chatTabGroup.tabInfo.contains(where: { $0.id == tab.id }) else { return .none } state.chatTabGroup.tabInfo.append(tab) state.chatTabGroup.selectedTabId = tab.id - return .none + return .run { send in + await send(.focusActiveChatTab) + } case .switchToNextTab: let selectedId = state.chatTabGroup.selectedTabId @@ -192,7 +198,9 @@ public struct ChatPanelFeature: ReducerProtocol { } let targetId = state.chatTabGroup.tabInfo[nextIndex].id state.chatTabGroup.selectedTabId = targetId - return .none + return .run { send in + await send(.focusActiveChatTab) + } case .switchToPreviousTab: let selectedId = state.chatTabGroup.selectedTabId @@ -205,7 +213,9 @@ public struct ChatPanelFeature: ReducerProtocol { } let targetId = state.chatTabGroup.tabInfo[previousIndex].id state.chatTabGroup.selectedTabId = targetId - return .none + return .run { send in + await send(.focusActiveChatTab) + } case let .moveChatTab(from, to): guard from >= 0, from < state.chatTabGroup.tabInfo.endIndex, to >= 0, @@ -217,6 +227,13 @@ public struct ChatPanelFeature: ReducerProtocol { state.chatTabGroup.tabInfo.remove(at: from) state.chatTabGroup.tabInfo.insert(tab, at: to) return .none + + case .focusActiveChatTab: + let id = state.chatTabGroup.selectedTabInfo?.id + guard let id else { return .none } + return .run { send in + await send(.chatTab(id: id, action: .focus)) + } case let .chatTab(id, .close): return .run { send in diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index e090a676..589a8a11 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -37,6 +37,7 @@ public struct PanelFeature: ReducerProtocol { @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activateThisApp) var activateThisApp var windows: WidgetWindows { suggestionWidgetControllerDependency.windows } public var body: some ReducerProtocol { @@ -110,7 +111,7 @@ public struct PanelFeature: ReducerProtocol { case .removeDisplayedContent: state.content.error = nil - state.content.promptToCodeGroup.activePromptToCode = nil + state.content.promptToCodeGroup.activeDocumentURL = nil state.content.suggestion = nil return .none @@ -121,9 +122,7 @@ public struct PanelFeature: ReducerProtocol { await send(.displayPanelContent) if hasPromptToCode { - // looks like we need a delay. - try await Task.sleep(nanoseconds: 150_000_000) - await NSApplication.shared.activate(ignoringOtherApps: true) + activateThisApp() await windows.sharedPanelWindow.makeKey() } }.animation(.easeInOut(duration: 0.2)) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift index bfac06d0..a4f5886c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -43,6 +43,10 @@ public struct PromptToCode: ReducerProtocol { } } } + + public enum FocusField: Equatable { + case textField + } public var id: URL { documentURL } public var history: HistoryNode @@ -63,6 +67,7 @@ public struct PromptToCode: ReducerProtocol { @BindingState public var prompt: String @BindingState public var isContinuous: Bool @BindingState public var isAttachedToSelectionRange: Bool + @BindingState public var focusedField: FocusField? = .textField public var filename: String { documentURL.lastPathComponent } public var canRevert: Bool { history != .empty } @@ -114,6 +119,7 @@ public struct PromptToCode: ReducerProtocol { public enum Action: Equatable, BindableAction { case binding(BindingAction) + case focusOnTextField case selectionRangeToggleTapped case modifyCodeButtonTapped case revertButtonTapped @@ -142,6 +148,10 @@ public struct PromptToCode: ReducerProtocol { switch action { case .binding: return .none + + case .focusOnTextField: + state.focusedField = .textField + return .none case .selectionRangeToggleTapped: state.isAttachedToSelectionRange.toggle() diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift index 9012535a..339b45e8 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -18,7 +18,12 @@ public struct PromptToCodeGroup: ReducerProtocol { guard let id = activeDocumentURL else { return nil } return promptToCodes[id: id] } - set { activeDocumentURL = newValue?.id } + set { + activeDocumentURL = newValue?.id + if let id = newValue?.id { + promptToCodes[id: id] = newValue + } + } } } @@ -77,16 +82,20 @@ public struct PromptToCodeGroup: ReducerProtocol { case updateActivePromptToCode(documentURL: URL) case discardExpiredPromptToCode(documentURLs: [URL]) case promptToCode(PromptToCode.State.ID, PromptToCode.Action) + case activePromptToCode(PromptToCode.Action) } @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory + @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode public var body: some ReducerProtocol { Reduce { state, action in switch action { case let .activateOrCreatePromptToCode(s): - guard state.activePromptToCode == nil else { - return .none + if let promptToCode = state.activePromptToCode { + return .run { send in + await send(.promptToCode(promptToCode.id, .focusOnTextField)) + } } return .run { send in await send(.createPromptToCode(s)) @@ -138,22 +147,38 @@ public struct PromptToCodeGroup: ReducerProtocol { } return .none - case let .promptToCode(id, action): - switch action { - case .cancelButtonTapped: - state.promptToCodes.remove(id: id) - return .run { _ in - try await Environment.makeXcodeActive() - } - default: - return .none - } + case .promptToCode: + return .none + + case .activePromptToCode: + return .none } } + .ifLet(\.activePromptToCode, action: /Action.activePromptToCode) { + PromptToCode() + .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + } .forEach(\.promptToCodes, action: /Action.promptToCode, element: { PromptToCode() .dependency(\.promptToCodeService, promptToCodeServiceFactory()) }) + + Reduce { state, action in + switch action { + case let .promptToCode(id, .cancelButtonTapped): + state.promptToCodes.remove(id: id) + return .run { _ in + activatePreviousActiveXcode() + } + case .activePromptToCode(.cancelButtonTapped): + guard let id = state.activePromptToCode?.id else { return .none } + state.promptToCodes.remove(id: id) + return .run { _ in + activatePreviousActiveXcode() + } + default: return .none + } + } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 7e5510c0..3465e422 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -1,4 +1,5 @@ import ActiveApplicationMonitor +import AppActivator import AsyncAlgorithms import AXNotificationStream import ComposableArchitecture @@ -22,6 +23,11 @@ public struct WidgetFeature: ReducerProtocol { public var tabWindowState = WindowState() } + public enum WindowCanBecomeKey: Equatable { + case sharedPanel + case chatPanel + } + public struct State: Equatable { var focusingDocumentURL: URL? public var colorScheme: ColorScheme = .light @@ -112,6 +118,7 @@ public struct WidgetFeature: ReducerProtocol { case updateWindowOpacity case updateFocusingDocumentURL case updateWindowOpacityFinished + case updateKeyWindow(WindowCanBecomeKey) case panel(PanelFeature.Action) case chatPanel(ChatPanelFeature.Action) @@ -127,6 +134,8 @@ public struct WidgetFeature: ReducerProtocol { @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor @Dependency(\.xcodeInspector) var xcodeInspector @Dependency(\.mainQueue) var mainQueue + @Dependency(\.activateThisApp) var activateThisApp + @Dependency(\.activatePreviousActiveApp) var activatePreviousActiveApp public enum DebounceKey: Hashable { case updateWindowOpacity @@ -147,8 +156,8 @@ public struct WidgetFeature: ReducerProtocol { } case .circularWidget(.widgetClicked): - let isDisplayingContent = state._circularWidgetState.isDisplayingContent - if isDisplayingContent { + let wasDisplayingContent = state._circularWidgetState.isDisplayingContent + if wasDisplayingContent { state.panelState.sharedPanelState.isPanelDisplayed = false state.panelState.suggestionPanelState.isPanelDisplayed = false state.chatPanelState.isPanelDisplayed = false @@ -157,11 +166,26 @@ public struct WidgetFeature: ReducerProtocol { state.panelState.suggestionPanelState.isPanelDisplayed = true state.chatPanelState.isPanelDisplayed = true } - return .run { _ in - guard isDisplayingContent else { return } - if let app = activeApplicationMonitor.previousApp, app.isXcode { - try await Task.sleep(nanoseconds: 200_000_000) - app.activate() + + let isDisplayingContent = state._circularWidgetState.isDisplayingContent + let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil + let hasPromptToCode = state.panelState.sharedPanelState.content + .promptToCodeGroup.activePromptToCode != nil + + return .run { send in + if isDisplayingContent { + if hasPromptToCode { + await send(.updateKeyWindow(.sharedPanel)) + } else if hasChat { + await send(.updateKeyWindow(.chatPanel)) + } + await send(.chatPanel(.focusActiveChatTab)) + } + + if isDisplayingContent, !(await NSApplication.shared.isActive) { + activateThisApp() + } else if !isDisplayingContent { + activatePreviousActiveApp() } } @@ -583,6 +607,16 @@ public struct WidgetFeature: ReducerProtocol { state.lastUpdateWindowOpacityTime = Date() return .none + case let .updateKeyWindow(window): + return .run { _ in + switch window { + case .chatPanel: + await windows.chatPanelWindow.makeKeyAndOrderFront(nil) + case .sharedPanel: + await windows.sharedPanelWindow.makeKeyAndOrderFront(nil) + } + } + case .circularWidget: return .none diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index 0a38ee6e..3fbebeab 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -78,23 +78,6 @@ struct ChatTabBuilderCollectionKey: DependencyKey { static let liveValue: () -> [ChatTabBuilderCollection] = { [] } } -struct ActivatePreviouslyActiveXcodeKey: DependencyKey { - static let liveValue = { @MainActor in - @Dependency(\.activeApplicationMonitor) var activeApplicationMonitor - if let app = activeApplicationMonitor.previousApp, app.isXcode { - try? await Task.sleep(nanoseconds: 200_000_000) - app.activate() - } - } -} - -struct ActivateExtensionServiceKey: DependencyKey { - static let liveValue = { @MainActor in - try? await Task.sleep(nanoseconds: 150_000_000) - NSApplication.shared.activate(ignoringOtherApps: true) - } -} - public extension DependencyValues { var suggestionWidgetControllerDependency: SuggestionWidgetControllerDependency { get { self[SuggestionWidgetControllerDependencyKey.self] } @@ -122,15 +105,5 @@ extension DependencyValues { get { self[ActiveApplicationMonitorKey.self] } set { self[ActiveApplicationMonitorKey.self] = newValue } } - - var activatePreviouslyActiveXcode: () async -> Void { - get { self[ActivatePreviouslyActiveXcodeKey.self] } - set { self[ActivatePreviouslyActiveXcodeKey.self] = newValue } - } - - var activateExtensionService: () async -> Void { - get { self[ActivateExtensionServiceKey.self] } - set { self[ActivateExtensionServiceKey.self] = newValue } - } } diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift index cf4bac14..c16f6cc1 100644 --- a/Core/Sources/SuggestionWidget/SharedPanelView.swift +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -57,13 +57,16 @@ struct SharedPanelView: View { ) } } else if let promptToCode = viewStore.state.promptToCode { - PromptToCodePanel(store: store.scope( - state: { _ in promptToCode }, + IfLetStore(store.scope( + state: { $0.content.promptToCodeGroup.activePromptToCode }, action: { SharedPanelFeature.Action - .promptToCodeGroup(.promptToCode(promptToCode.id, $0)) + .promptToCodeGroup(.activePromptToCode($0)) } - )) + )) { + PromptToCodePanel(store: $0) + } + } else if let suggestion = viewStore.state.suggestion { switch suggestionPresentationMode { case .nearbyTextCursor: diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index b20190aa..0f3ae4f0 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -290,7 +290,7 @@ extension PromptToCodePanel { struct Toolbar: View { let store: StoreOf - @FocusState var isInputAreaFocused: Bool + @FocusState var focusField: PromptToCode.State.FocusField? struct RevertButtonState: Equatable { var isResponding: Bool @@ -299,6 +299,7 @@ extension PromptToCodePanel { struct InputFieldState: Equatable { @BindingViewState var prompt: String + @BindingViewState var focusField: PromptToCode.State.FocusField? var isResponding: Bool } @@ -326,15 +327,12 @@ extension PromptToCodePanel { .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) } .background { - Button(action: { isInputAreaFocused = true }) { + Button(action: { focusField = .textField }) { EmptyView() } .keyboardShortcut("l", modifiers: [.command]) } } - .onAppear { - isInputAreaFocused = true - } .padding(8) .background(.ultraThickMaterial) } @@ -366,7 +364,13 @@ extension PromptToCodePanel { var inputField: some View { WithViewStore( store, - observe: { InputFieldState(prompt: $0.$prompt, isResponding: $0.isResponding) } + observe: { + InputFieldState( + prompt: $0.$prompt, + focusField: $0.$focusedField, + isResponding: $0.isResponding + ) + } ) { viewStore in ZStack(alignment: .center) { // a hack to support dynamic height of TextEditor @@ -389,8 +393,9 @@ extension PromptToCodePanel { .opacity(viewStore.state.isResponding ? 0.5 : 1) .disabled(viewStore.state.isResponding) } + .focused($focusField, equals: .textField) + .bind(viewStore.$focusField, to: $focusField) } - .focused($isInputAreaFocused) .padding(8) .fixedSize(horizontal: false, vertical: true) } diff --git a/Pro b/Pro index ce6f1630..22e5f0a2 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit ce6f1630793d46564d658d511d423048edc443f3 +Subproject commit 22e5f0a2a07e6ddc1c95320c65dcac6305b8683b diff --git a/Tool/Package.swift b/Tool/Package.swift index d8af318b..de775e9a 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -39,6 +39,7 @@ let package = Package( "ActiveApplicationMonitor", "AXExtension", "AXNotificationStream", + "AppActivator", ] ), ], @@ -111,6 +112,14 @@ let package = Package( ] ), + .target( + name: "AppActivator", + dependencies: [ + "XcodeInspector", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .target(name: "ActiveApplicationMonitor"), .target(name: "USearchIndex", dependencies: [ diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift new file mode 100644 index 00000000..ed83287a --- /dev/null +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -0,0 +1,106 @@ +import AppKit +import Dependencies +import XcodeInspector + +public extension NSWorkspace { + static func activateThisApp(delay: TimeInterval = 0.3) { + Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + // NSApp.activate may fail. And since macOS 14, it looks like the app needs other + // apps to call `yieldActivationToApplication` to activate itself? + + let activated = NSRunningApplication.current + .activate(options: [.activateIgnoringOtherApps]) + + if activated { return } + + // Fallback solution + + let appleScript = """ + tell application "System Events" + set frontmost of the first process whose unix id is \ + \(ProcessInfo.processInfo.processIdentifier) to true + end tell + """ + try await runAppleScript(appleScript) + } + } + + static func activatePreviousActiveApp(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = XcodeInspector.shared.previousActiveApplication else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + app.runningApplication.activate() + } + } + + static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = XcodeInspector.shared.latestActiveXcode else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + app.runningApplication.activate() + } + } +} + +struct ActivateThisAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activateThisApp() } +} + +struct ActivatePreviousActiveAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveApp() } +} + +struct ActivatePreviousActiveXcodeDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveXcode() } +} + +public extension DependencyValues { + var activateThisApp: () -> Void { + get { self[ActivateThisAppDependencyKey.self] } + set { self[ActivateThisAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveApp: () -> Void { + get { self[ActivatePreviousActiveAppDependencyKey.self] } + set { self[ActivatePreviousActiveAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveXcode: () -> Void { + get { self[ActivatePreviousActiveXcodeDependencyKey.self] } + set { self[ActivatePreviousActiveXcodeDependencyKey.self] = newValue } + } +} + +@discardableResult +func runAppleScript(_ appleScript: String) async throws -> String { + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", appleScript] + let outpipe = Pipe() + task.standardOutput = outpipe + task.standardError = Pipe() + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { _ in + do { + if let data = try outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(returning: content) + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 1c09ef5e..cc10c240 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -6,6 +6,7 @@ import SwiftUI public struct ChatTabInfo: Identifiable, Equatable { public var id: String public var title: String + public var focusTrigger: Int = 0 public init(id: String, title: String) { self.id = id diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift index 128af1e7..f54f8085 100644 --- a/Tool/Sources/ChatTab/ChatTabItem.swift +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -21,6 +21,7 @@ public struct ChatTabItem: ReducerProtocol { case openNewTab(AnyChatTabBuilder) case tabContentUpdated case close + case focus } public init() {} @@ -37,6 +38,9 @@ public struct ChatTabItem: ReducerProtocol { return .none case .close: return .none + case .focus: + state.focusTrigger += 1 + return .none } } } diff --git a/Tool/Sources/Environment/Environment.swift b/Tool/Sources/Environment/Environment.swift index f1315daf..5beae16d 100644 --- a/Tool/Sources/Environment/Environment.swift +++ b/Tool/Sources/Environment/Environment.swift @@ -223,15 +223,6 @@ public enum Environment { } } } - - public static var makeXcodeActive: () async throws -> Void = { - let appleScript = """ - tell application "Xcode" - activate - end tell - """ - try await runAppleScript(appleScript) - } } @discardableResult diff --git a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift index ee37adea..5203bb51 100644 --- a/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift +++ b/Tool/Sources/FocusedCodeFinder/FocusedCodeFinder.swift @@ -72,11 +72,15 @@ public struct UnknownLanguageFocusedCodeFinder: FocusedCodeFinder { let lines = activeDocumentContext.lines let proposedLineCount = proposedSearchRange * 2 + 1 let startLineIndex = max(containingRange.start.line - proposedSearchRange, 0) - let endLineIndex = max( - startLineIndex, - min(startLineIndex + proposedLineCount - 1, lines.count - 1) + let endLineIndex = min( + max( + startLineIndex, + min(startLineIndex + proposedLineCount - 1, lines.count - 1) + ), + lines.count - 1 ) + guard endLineIndex >= startLineIndex else { return .empty } let focusedLines = lines[startLineIndex...endLineIndex] let contextStartLine = max(startLineIndex - 5, 0) diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 640bf73a..b8aa94a0 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -100,12 +100,13 @@ public class ChatGPTService: ChatGPTServiceType { return Debugger.$id.withValue(.init()) { AsyncThrowingStream { continuation in - Task(priority: .userInitiated) { + let task = Task(priority: .userInitiated) { do { var functionCall: ChatMessage.FunctionCall? var functionCallMessageID = "" var isInitialCall = true loop: while functionCall != nil || isInitialCall { + try Task.checkCancellation() isInitialCall = false if let call = functionCall { if !configuration.runFunctionsAutomatically { @@ -121,6 +122,7 @@ public class ChatGPTService: ChatGPTServiceType { #endif for try await content in stream { + try Task.checkCancellation() switch content { case let .text(text): continuation.yield(text) @@ -154,6 +156,9 @@ public class ChatGPTService: ChatGPTServiceType { continuation.finish(throwing: error) } } + continuation.onTermination = { _ in + task.cancel() + } } } } @@ -177,6 +182,7 @@ public class ChatGPTService: ChatGPTServiceType { var finalResult = message?.content var functionCall = message?.functionCall while let call = functionCall { + try Task.checkCancellation() if !configuration.runFunctionsAutomatically { break } @@ -270,12 +276,13 @@ extension ChatGPTService { #endif return AsyncThrowingStream { continuation in - Task { + let task = Task { do { let (trunks, cancel) = try await api() cancelTask = cancel - let proposedId = UUID().uuidString + let proposedId = UUID().uuidString + String(Date().timeIntervalSince1970) for try await trunk in trunks { + try Task.checkCancellation() guard let delta = trunk.choices?.first?.delta else { continue } // The api will always return a function call with JSON object. @@ -290,7 +297,7 @@ extension ChatGPTService { } await memory.streamMessage( - id: trunk.id ?? proposedId, + id: proposedId, role: delta.role, content: delta.content, functionCall: functionCall @@ -320,6 +327,10 @@ extension ChatGPTService { continuation.finish(throwing: error) } } + + continuation.onTermination = { _ in + task.cancel() + } } } diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index aa441c67..9f464233 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -11,6 +11,7 @@ public protocol ChatGPTConfiguration { var maxTokens: Int { get } var minimumReplyTokens: Int { get } var runFunctionsAutomatically: Bool { get } + var shouldEndTextWindow: (String) -> Bool { get } } public extension ChatGPTConfiguration { diff --git a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift index 54b7fbf2..2272d60e 100644 --- a/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/UserPreferenceChatGPTConfiguration.swift @@ -40,6 +40,10 @@ public struct UserPreferenceChatGPTConfiguration: ChatGPTConfiguration { public var runFunctionsAutomatically: Bool { true } + + public var shouldEndTextWindow: (String) -> Bool { + { _ in true } + } public init(chatModelKey: KeyPath>? = nil) { self.chatModelKey = chatModelKey @@ -56,7 +60,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public var minimumReplyTokens: Int? public var runFunctionsAutomatically: Bool? public var apiKey: String? - + public init( temperature: Double? = nil, modelId: String? = nil, @@ -80,6 +84,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { private let configuration: ChatGPTConfiguration public var overriding = Overriding() + public var textWindowTerminator: ((String) -> Bool)? public init( overriding configuration: any ChatGPTConfiguration, @@ -126,5 +131,9 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { guard let name = model?.info.apiKeyName else { return configuration.apiKey } return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey } + + public var shouldEndTextWindow: (String) -> Bool { + textWindowTerminator ?? configuration.shouldEndTextWindow + } } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index cd3f54fd..748af74d 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -14,6 +14,7 @@ public final class XcodeInspector: ObservableObject { private var activeXcodeCancellable = Set() @Published public internal(set) var activeApplication: AppInstanceInspector? + @Published public internal(set) var previousActiveApplication: AppInstanceInspector? @Published public internal(set) var activeXcode: XcodeAppInstanceInspector? @Published public internal(set) var latestActiveXcode: XcodeAppInstanceInspector? @Published public internal(set) var xcodes: [XcodeAppInstanceInspector] = [] @@ -110,6 +111,7 @@ public final class XcodeInspector: ObservableObject { setActiveXcode(new) } } else { + previousActiveApplication = activeApplication activeApplication = AppInstanceInspector(runningApplication: app) } } @@ -145,6 +147,7 @@ public final class XcodeInspector: ObservableObject { @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + previousActiveApplication = activeApplication activeApplication = xcode xcode.refresh() for task in activeXcodeObservations { task.cancel() } diff --git a/Version.xcconfig b/Version.xcconfig index 7664fb23..0a2b0d10 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.27.0 -APP_BUILD = 280 +APP_VERSION = 0.27.1 +APP_BUILD = 281 diff --git a/appcast.xml b/appcast.xml index 19c2b094..b9eea600 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.27.1 + Sat, 18 Nov 2023 12:46:36 +0800 + 281 + 0.27.1 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.27.1 + + + + 0.27.0 Fri, 10 Nov 2023 02:34:25 +0800