diff --git a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift index 7a632d3f3..078fb96c7 100644 --- a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift +++ b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift @@ -218,6 +218,8 @@ final class GlobalKeyboardShortcutsCoordinator { register(.nextParentAgentSession) { [weak self] in self?.focusAdjacentParentAgentSession(forward: true) } register(.showCurrentWindowAgentNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .currentWindow) } register(.showAllAgentsNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .allAgents) } + register(.showAgentModelPicker) { [weak self] in self?.showAgentModelPicker() } + register(.showAgentEffortPicker) { [weak self] in self?.showAgentEffortPicker() } } private func startNewAgentSessionFromShortcut() { @@ -274,4 +276,22 @@ final class GlobalKeyboardShortcutsCoordinator { ] ) } + + private func showAgentModelPicker() { + guard let win = guardedFocusedWindowState() else { return } + NotificationCenter.default.post( + name: .showAgentModelPicker, + object: nil, + userInfo: ["windowID": win.windowID] + ) + } + + private func showAgentEffortPicker() { + guard let win = guardedFocusedWindowState() else { return } + NotificationCenter.default.post( + name: .showAgentEffortPicker, + object: nil, + userInfo: ["windowID": win.windowID] + ) + } } diff --git a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift index 32e830b5d..bc4b08ee1 100644 --- a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift +++ b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift @@ -95,4 +95,12 @@ extension Notification.Name { /// ⌘1–⌘9 handlers before they fall through to regular compose-tab switching. /// userInfo: ["windowID": Int, "resultIndex": Int, "handledRequest": AgentNavigationHUDHandledRequest] static let selectAgentNavigationHUDResult = Notification.Name("selectAgentNavigationHUDResult") + + /// Request that Agent Mode open the Agent/Model picker for the target window. + /// userInfo: ["windowID": Int] + static let showAgentModelPicker = Notification.Name("showAgentModelPicker") + + /// Request that Agent Mode open the active provider's effort picker for the target window. + /// userInfo: ["windowID": Int] + static let showAgentEffortPicker = Notification.Name("showAgentEffortPicker") } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift index 0a95092a7..6c57459c2 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift @@ -44,6 +44,7 @@ extension AgentModeViewModel { selectedModelDisplayName: selectedModelDisplayName, selectedReasoningEffortRaw: selectedReasoningEffortRaw, selectedReasoningEffortDisplayName: selectedReasoningEffortDisplayName, + codexReasoningEffortOptions: reasoningEffortOptionsForCurrentSelection(), availableAgents: availableAgents, isProviderPickerLockedForCurrentTab: isProviderPickerLocked(tabID: tabID), lockedAgentSelectionMessage: lockedAgentSelectionMessage(tabID: tabID), diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift index 31cdaa370..73f518859 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift @@ -234,6 +234,7 @@ struct AgentComposerProps: Equatable { let selectedModelDisplayName: String let selectedReasoningEffortRaw: String? let selectedReasoningEffortDisplayName: String + let codexReasoningEffortOptions: [CodexReasoningEffort] let availableAgents: [AgentProviderKind] let isProviderPickerLockedForCurrentTab: Bool let lockedAgentSelectionMessage: String? @@ -266,6 +267,7 @@ struct AgentComposerProps: Equatable { selectedModelDisplayName: AgentModel.defaultModel.displayName, selectedReasoningEffortRaw: nil, selectedReasoningEffortDisplayName: "", + codexReasoningEffortOptions: [], availableAgents: [], isProviderPickerLockedForCurrentTab: false, lockedAgentSelectionMessage: nil, diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index 1a64e1967..71b4fb976 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -25,7 +25,6 @@ struct AgentComposerActions { let modelOptions: (_ agent: AgentProviderKind, _ includeClaudeEffortVariants: Bool) -> [AgentModelOption] let canSelectAgentInCurrentChat: (_ agent: AgentProviderKind) -> Bool let selectAgentModel: (_ agent: AgentProviderKind, _ rawModel: String) -> Void - let reasoningEffortOptionsForCurrentSelection: () -> [CodexReasoningEffort] let selectReasoningEffort: (_ effort: CodexReasoningEffort?) -> Void let setAutoEditEnabled: (_ enabled: Bool) -> Void let setProviderPermissionLevel: (_ id: AgentProviderPermissionLevelID) -> Void @@ -136,7 +135,6 @@ struct AgentInputBar: View { agentModeVM.selectedAgent = agent agentModeVM.selectModel(rawModel: rawModel) }, - reasoningEffortOptionsForCurrentSelection: { agentModeVM.reasoningEffortOptionsForCurrentSelection() }, selectReasoningEffort: { effort in agentModeVM.selectReasoningEffort(effort) }, setAutoEditEnabled: { enabled in agentModeVM.setAutoEditEnabled(enabled) }, setProviderPermissionLevel: { id in agentModeVM.setProviderPermissionLevel(id) }, @@ -176,6 +174,141 @@ struct AgentInputBar: View { } } +enum AgentPickerOpenRequestWindowGuard { + static func shouldOpen( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + guard requestWindowID == composerWindowID else { return false } + guard let composerCurrentTabID, composerCurrentTabID == propsCurrentTabID else { return false } + guard hasAvailableAgentProviders else { return false } + guard !modelControlsDisabled else { return false } + // Locked provider pickers still open on click to show the lock message; + // shortcut requests intentionally match that behavior. + return true + } + + static func windowID(from userInfo: [AnyHashable: Any]?) -> Int? { + userInfo?["windowID"] as? Int + } +} + +enum AgentModelPickerOpenRequestGuard { + static func shouldOpen( + notification: Notification, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + shouldOpen( + requestWindowID: AgentPickerOpenRequestWindowGuard.windowID(from: notification.userInfo), + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) + } + + static func shouldOpen( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + AgentPickerOpenRequestWindowGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) + } +} + +enum AgentEffortPickerOpenTarget: Equatable { + case codexReasoningEffort + case claudeEffortLevel +} + +private struct AgentPickerPresentationID: Hashable { + enum Kind: Hashable { + case model + case codexEffort + case claudeEffort + } + + let kind: Kind + let tabID: UUID? + let selectedAgent: AgentProviderKind + let selectedModelRaw: String + let optionRawValues: [String] +} + +enum AgentEffortPickerOpenRequestGuard { + static func target( + notification: Notification, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool, + claudeEffortsAvailable: Bool + ) -> AgentEffortPickerOpenTarget? { + target( + requestWindowID: AgentPickerOpenRequestWindowGuard.windowID(from: notification.userInfo), + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: selectedAgent, + codexEffortsAvailable: codexEffortsAvailable, + claudeEffortsAvailable: claudeEffortsAvailable + ) + } + + static func target( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool, + claudeEffortsAvailable: Bool + ) -> AgentEffortPickerOpenTarget? { + guard AgentPickerOpenRequestWindowGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) else { return nil } + + if selectedAgent == .codexExec, codexEffortsAvailable { + return .codexReasoningEffort + } + if selectedAgent.usesClaudeTooling, claudeEffortsAvailable { + return .claudeEffortLevel + } + return nil + } +} + enum AgentFileMentionText { static func attachmentDisplayName(for suggestion: MentionSuggestion) -> String { let trimmedPath = suggestion.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) @@ -266,6 +399,9 @@ struct AgentComposerView: View, Equatable { @State private var showClaudeToolsPopover: Bool = false @State private var steeringUnsupportedMessage: String? = nil @State private var steeringUnsupportedDismissTask: Task? + @State private var modelPickerOpenRequestCount: Int = 0 + @State private var codexEffortPickerOpenRequestCount: Int = 0 + @State private var claudeEffortPickerOpenRequestCount: Int = 0 @State private var modelMenuSnapshotByAgent: [AgentProviderKind: [AgentModelOption]]? = nil @State private var modelMenuSnapshotReleaseTask: Task? = nil @@ -355,6 +491,53 @@ struct AgentComposerView: View, Equatable { props.areModelControlsDisabled } + private var codexEffortsAreAvailable: Bool { + props.selectedAgent == .codexExec && !props.codexReasoningEffortOptions.isEmpty + } + + private var claudeEffortsAreAvailable: Bool { + props.selectedAgent.usesClaudeTooling + && props.providerControls?.claudeTools != nil + && !supportedClaudeEffortsForCurrentSelection().isEmpty + } + + private var modelPickerProgrammaticPresentationAllowed: Bool { + currentTabID != nil + && currentTabID == props.currentTabID + && props.hasAvailableAgentProviders + && !modelControlsDisabled + } + + private var modelPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .model, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: props.availableAgents.map(\.rawValue) + ) + } + + private var codexEffortPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .codexEffort, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: props.codexReasoningEffortOptions.map(\.rawValue) + ) + } + + private var claudeEffortPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .claudeEffort, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: supportedClaudeEffortsForCurrentSelection().map(\.rawValue) + ) + } + private var modelControlsDisabledTooltip: String { "Model and effort controls are locked while this session is controlled by an MCP agent." } @@ -538,6 +721,36 @@ struct AgentComposerView: View, Equatable { showSteeringUnsupportedNotice(event.message) } } + .onReceive(NotificationCenter.default.publisher(for: .showAgentModelPicker)) { notification in + guard AgentModelPickerOpenRequestGuard.shouldOpen( + notification: notification, + composerWindowID: windowID, + composerCurrentTabID: currentTabID, + propsCurrentTabID: props.currentTabID, + hasAvailableAgentProviders: props.hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) else { return } + modelPickerOpenRequestCount += 1 + } + .onReceive(NotificationCenter.default.publisher(for: .showAgentEffortPicker)) { notification in + guard let target = AgentEffortPickerOpenRequestGuard.target( + notification: notification, + composerWindowID: windowID, + composerCurrentTabID: currentTabID, + propsCurrentTabID: props.currentTabID, + hasAvailableAgentProviders: props.hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: props.selectedAgent, + codexEffortsAvailable: codexEffortsAreAvailable, + claudeEffortsAvailable: claudeEffortsAreAvailable + ) else { return } + switch target { + case .codexReasoningEffort: + codexEffortPickerOpenRequestCount += 1 + case .claudeEffortLevel: + claudeEffortPickerOpenRequestCount += 1 + } + } .onDrop(of: [UTType.fileURL, UTType.image], isTargeted: $isImageDropTargeted, perform: handleImageDrop(providers:)) .overlay(imageDropOutline) } @@ -804,6 +1017,9 @@ struct AgentComposerView: View, Equatable { StableMenuButton( items: agentProviderModelMenuItems, triggerStyle: .plain, + openRequestCount: modelPickerOpenRequestCount, + programmaticPresentationID: modelPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed, onOpen: captureModelMenuSnapshot ) { HStack(spacing: 4) { @@ -945,33 +1161,25 @@ struct AgentComposerView: View, Equatable { @ViewBuilder private var reasoningEffortPicker: some View { if props.selectedAgent == .codexExec { - let efforts = actions.reasoningEffortOptionsForCurrentSelection() - Menu { - ForEach(efforts, id: \.rawValue) { effort in - Button { - actions.selectReasoningEffort(effort) - } label: { - HStack { - Text(effort.displayName) - if props.selectedReasoningEffortRaw == effort.rawValue { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } label: { + let efforts = props.codexReasoningEffortOptions + StableMenuButton( + items: { reasoningEffortMenuItems(efforts: efforts) }, + triggerStyle: .plain, + openRequestCount: codexEffortPickerOpenRequestCount, + programmaticPresentationID: codexEffortPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed && codexEffortsAreAvailable + ) { HStack(spacing: 4) { Text(props.selectedReasoningEffortDisplayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) + Image(systemName: "chevron.down") + .font(fontPreset.swiftUIFont(sizeAtNormal: 8, weight: .semibold)) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(pickerChipColor) - .cornerRadius(4) } - .menuStyle(.borderlessButton) .disabled(efforts.isEmpty || modelControlsDisabled) .opacity(modelControlsDisabled ? 0.55 : 1.0) .hoverTooltip(modelControlsDisabled ? modelControlsDisabledTooltip : "Codex reasoning effort") @@ -979,41 +1187,41 @@ struct AgentComposerView: View, Equatable { } } + private func reasoningEffortMenuItems(efforts: [CodexReasoningEffort]) -> [StableMenuItem] { + efforts.map { effort in + StableMenuItem.action( + effort.displayName, + isSelected: props.selectedReasoningEffortRaw == effort.rawValue + ) { + actions.selectReasoningEffort(effort) + } + } + } + @ViewBuilder private var claudeEffortPicker: some View { if props.selectedAgent.usesClaudeTooling, let claudeTools = props.providerControls?.claudeTools { - let efforts = AgentModelCatalog.supportedClaudeEfforts( - forSelectedModelRaw: props.selectedModelRaw, - agentKind: props.selectedAgent - ) - Menu { - ForEach(efforts, id: \.rawValue) { level in - Button { - actions.setClaudeEffortLevel(level) - } label: { - HStack { - Text(level.displayName) - if claudeTools.effortLevel == level { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } label: { + let efforts = supportedClaudeEffortsForCurrentSelection() + StableMenuButton( + items: { claudeEffortMenuItems(efforts: efforts, selectedLevel: claudeTools.effortLevel) }, + triggerStyle: .plain, + openRequestCount: claudeEffortPickerOpenRequestCount, + programmaticPresentationID: claudeEffortPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed && claudeEffortsAreAvailable + ) { HStack(spacing: 4) { Text(claudeTools.effortLevel.displayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) + Image(systemName: "chevron.down") + .font(fontPreset.swiftUIFont(sizeAtNormal: 8, weight: .semibold)) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(pickerChipColor) - .cornerRadius(4) } - .menuStyle(.borderlessButton) .disabled(modelControlsDisabled || efforts.isEmpty) .opacity(modelControlsDisabled ? 0.55 : 1.0) .fixedSize() @@ -1021,6 +1229,27 @@ struct AgentComposerView: View, Equatable { } } + private func claudeEffortMenuItems( + efforts: [ClaudeCodeEffortLevel], + selectedLevel: ClaudeCodeEffortLevel + ) -> [StableMenuItem] { + efforts.map { level in + StableMenuItem.action( + level.displayName, + isSelected: selectedLevel == level + ) { + actions.setClaudeEffortLevel(level) + } + } + } + + private func supportedClaudeEffortsForCurrentSelection() -> [ClaudeCodeEffortLevel] { + AgentModelCatalog.supportedClaudeEfforts( + forSelectedModelRaw: props.selectedModelRaw, + agentKind: props.selectedAgent + ) + } + @ViewBuilder private var codexToolsButton: some View { if props.selectedAgent == .codexExec { diff --git a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift index e92a9b990..6c3a54ee8 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift @@ -26,7 +26,9 @@ enum KeyboardShortcutCatalog { name: .toggleContextComposer ), .init(id: "agent-nav-current", title: "Show Agent Session Switcher", detail: "Jump between Agent sessions in the focused window.", name: .showCurrentWindowAgentNavigationHUD), - .init(id: "agent-nav-all", title: "Search all Agent sessions", detail: "Jump to active or recent Agent sessions across windows.", name: .showAllAgentsNavigationHUD) + .init(id: "agent-nav-all", title: "Search all Agent sessions", detail: "Jump to active or recent Agent sessions across windows.", name: .showAllAgentsNavigationHUD), + .init(id: "agent-model-picker", title: "Open Agent/Model picker", detail: "Open the model picker for the focused Agent session.", name: .showAgentModelPicker), + .init(id: "agent-effort-picker", title: "Open Agent effort picker", detail: "Open the effort picker exposed by the focused Agent session's selected provider.", name: .showAgentEffortPicker) ] ), KeyboardShortcutCatalogSection( diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift index fa6d21636..c7320b6b3 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift @@ -15,19 +15,29 @@ struct StableMenuButton: View { let items: () -> [StableMenuItem] let triggerStyle: TriggerStyle + let openRequestCount: Int + let programmaticPresentationID: AnyHashable? + let isProgrammaticPresentationAllowed: Bool let onOpen: @MainActor () -> Void @ViewBuilder let label: () -> Label + @Environment(\.isEnabled) private var isEnabled @StateObject private var presenter = StableMenuPresenter() init( items: @escaping () -> [StableMenuItem], triggerStyle: TriggerStyle = .automatic, + openRequestCount: Int = 0, + programmaticPresentationID: AnyHashable? = nil, + isProgrammaticPresentationAllowed: Bool = true, onOpen: @escaping @MainActor () -> Void = {}, @ViewBuilder label: @escaping () -> Label ) { self.items = items self.triggerStyle = triggerStyle + self.openRequestCount = openRequestCount + self.programmaticPresentationID = programmaticPresentationID + self.isProgrammaticPresentationAllowed = isProgrammaticPresentationAllowed self.onOpen = onOpen self.label = label } @@ -45,14 +55,35 @@ struct StableMenuButton: View { private var button: some View { Button { - onOpen() - presenter.present(items()) + presentMenu(expectedProgrammaticPresentationID: nil, requiresProgrammaticPermission: false) } label: { label() } .background( - StableMenuAnchorView(presenter: presenter) - .allowsHitTesting(false) + StableMenuAnchorView( + presenter: presenter, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: onOpen, + items: items + ) + .allowsHitTesting(false) + ) + .onChange(of: openRequestCount) { + presenter.enqueueProgrammaticPresentation(expectedPresentationID: programmaticPresentationID) + } + } + + private func presentMenu( + expectedProgrammaticPresentationID: AnyHashable?, + requiresProgrammaticPermission: Bool + ) { + presenter.present( + expectedProgrammaticPresentationID: expectedProgrammaticPresentationID, + requiresProgrammaticPermission: requiresProgrammaticPermission, + onOpen: onOpen, + items: items ) } } @@ -203,21 +234,76 @@ private extension NSMenu { } @MainActor -private final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { +final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { + typealias PopUpMenu = (NSMenu, NSPoint, NSView) -> Void + weak var anchorView: NSView? + private var isEnabled = true + private var programmaticPresentationID: AnyHashable? + private var isProgrammaticPresentationAllowed = true + private var currentOnOpen: (() -> Void)? + private var currentItems: (() -> [StableMenuItem])? private var retainedMenu: NSMenu? + private let popUpMenu: PopUpMenu + + init(popUpMenu: @escaping PopUpMenu = { menu, point, anchorView in + menu.popUp(positioning: nil, at: point, in: anchorView) + }) { + self.popUpMenu = popUpMenu + } + + func update( + anchorView: NSView, + isEnabled: Bool, + programmaticPresentationID: AnyHashable?, + isProgrammaticPresentationAllowed: Bool, + onOpen: @escaping () -> Void, + items: @escaping () -> [StableMenuItem] + ) { + self.anchorView = anchorView + self.isEnabled = isEnabled + self.programmaticPresentationID = programmaticPresentationID + self.isProgrammaticPresentationAllowed = isProgrammaticPresentationAllowed + currentOnOpen = onOpen + currentItems = items + } - func present(_ items: [StableMenuItem]) { - guard !items.isEmpty else { return } - guard let anchorView else { return } + func enqueueProgrammaticPresentation(expectedPresentationID: AnyHashable?) { + DispatchQueue.main.async { [weak self] in + guard let self, let currentOnOpen, let currentItems else { return } + present( + expectedProgrammaticPresentationID: expectedPresentationID, + requiresProgrammaticPermission: true, + onOpen: currentOnOpen, + items: currentItems + ) + } + } + + func present( + expectedProgrammaticPresentationID: AnyHashable?, + requiresProgrammaticPermission: Bool, + onOpen: () -> Void, + items: () -> [StableMenuItem] + ) { + guard isEnabled else { return } + guard let anchorView, anchorView.window != nil else { return } + if requiresProgrammaticPermission { + guard isProgrammaticPresentationAllowed else { return } + guard expectedProgrammaticPresentationID == programmaticPresentationID else { return } + } + + onOpen() + let menuItems = items() + guard !menuItems.isEmpty else { return } retainedMenu?.cancelTracking() - let menu = NSMenu.stableMenu(from: items, fontPreset: FontScalePreset.current) + let menu = NSMenu.stableMenu(from: menuItems, fontPreset: FontScalePreset.current) menu.delegate = self retainedMenu = menu let popupPoint = NSPoint(x: 0, y: anchorView.bounds.height + 2) - menu.popUp(positioning: nil, at: popupPoint, in: anchorView) + popUpMenu(menu, popupPoint, anchorView) } func menuDidClose(_ menu: NSMenu) { @@ -241,19 +327,35 @@ private final class StableMenuActionBox: NSObject { @MainActor private struct StableMenuAnchorView: NSViewRepresentable { @ObservedObject var presenter: StableMenuPresenter + let isEnabled: Bool + let programmaticPresentationID: AnyHashable? + let isProgrammaticPresentationAllowed: Bool + let onOpen: () -> Void + let items: () -> [StableMenuItem] func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) - presenter.anchorView = view + updatePresenter(anchorView: view) return view } func updateNSView(_ nsView: NSView, context: Context) { - presenter.anchorView = nsView + updatePresenter(anchorView: nsView) } static func dismantleNSView(_ nsView: NSView, coordinator: ()) { // Do not cancel tracking here. SwiftUI may rebuild the trigger while the AppKit // menu is open; retaining the menu through `StableMenuPresenter` is the point. } + + private func updatePresenter(anchorView: NSView) { + presenter.update( + anchorView: anchorView, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: onOpen, + items: items + ) + } } diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift index 51a053f03..8fd13fb2f 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift +++ b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift @@ -46,6 +46,10 @@ extension KeyboardShortcuts.Name { static let showCurrentWindowAgentNavigationHUD = Self("showCurrentWindowAgentNavigationHUD", default: .init(.k, modifiers: [.command])) /// Show the all-active/recent Agents navigation HUD. static let showAllAgentsNavigationHUD = Self("showAllAgentsNavigationHUD", default: .init(.k, modifiers: [.command, .shift])) + /// Open the focused Agent session's Agent/Model picker. + static let showAgentModelPicker = Self("showAgentModelPicker", default: .init(.m, modifiers: [.command, .shift])) + /// Open the focused Agent session's active effort picker. Unbound by default. + static let showAgentEffortPicker = Self("showAgentEffortPicker") /// Cycle to the previous root Agent session row. static let previousParentAgentSession = Self("previousParentAgentSession", default: .init(.leftBracket, modifiers: [.command, .option])) /// Cycle to the next root Agent session row. diff --git a/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift new file mode 100644 index 000000000..e4c580b46 --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift @@ -0,0 +1,310 @@ +import Foundation +@testable import RepoPromptApp +import XCTest + +final class AgentModelPickerOpenRequestGuardTests: XCTestCase { + func testAllowsMatchingWindowAndCurrentTab() { + let tabID = UUID() + let notification = Notification( + name: .showAgentModelPicker, + object: nil, + userInfo: ["windowID": 42] + ) + + XCTAssertTrue( + AgentModelPickerOpenRequestGuard.shouldOpen( + notification: notification, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: true, + modelControlsDisabled: false + ) + ) + } + + func testRejectsMismatchedWindow() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + requestWindowID: 41, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMissingWindow() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + requestWindowID: nil, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMissingComposerTab() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: nil, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMismatchedPropsTab() { + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: UUID(), + propsCurrentTabID: UUID() + ) + ) + } + + func testRejectsMissingProviders() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: false + ) + ) + } + + func testRejectsDisabledModelControls() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + modelControlsDisabled: true + ) + ) + } + + private func modelPickerShortcutGuardAllows( + requestWindowID: Int? = 42, + composerWindowID: Int = 42, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool = true, + modelControlsDisabled: Bool = false + ) -> Bool { + AgentModelPickerOpenRequestGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) + } +} + +final class AgentEffortPickerOpenRequestGuardTests: XCTestCase { + func testRoutesCodexEffortPickerWhenCodexEffortsAreAvailable() { + let tabID = UUID() + XCTAssertEqual( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ), + .codexReasoningEffort + ) + } + + func testNotificationOverloadRoutesByWindowUserInfo() { + let tabID = UUID() + let notification = Notification( + name: .showAgentEffortPicker, + object: nil, + userInfo: ["windowID": 42] + ) + + XCTAssertEqual( + AgentEffortPickerOpenRequestGuard.target( + notification: notification, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: true, + modelControlsDisabled: false, + selectedAgent: .codexExec, + codexEffortsAvailable: true, + claudeEffortsAvailable: false + ), + .codexReasoningEffort + ) + } + + func testRoutesClaudeEffortPickerWhenClaudeEffortsAreAvailable() { + let tabID = UUID() + XCTAssertEqual( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + claudeEffortsAvailable: true + ), + .claudeEffortLevel + ) + } + + func testRejectsAgentsWithoutEffortPicker() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .openCode, + codexEffortsAvailable: false, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsClaudeWhenOnlyCodexEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + codexEffortsAvailable: true, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsCodexWhenOnlyClaudeEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: false, + claudeEffortsAvailable: true + ) + ) + } + + func testRejectsCodexWhenNoEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: false + ) + ) + } + + func testRejectsClaudeWhenNoEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsMismatchedWindow() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + requestWindowID: 41, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMissingWindow() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + requestWindowID: nil, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMismatchedTab() { + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: UUID(), + propsCurrentTabID: UUID(), + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMissingProviders() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: false, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsDisabledModelControls() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + modelControlsDisabled: true, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + private func effortPickerShortcutTarget( + requestWindowID: Int? = 42, + composerWindowID: Int = 42, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool = true, + modelControlsDisabled: Bool = false, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool = false, + claudeEffortsAvailable: Bool = false + ) -> AgentEffortPickerOpenTarget? { + AgentEffortPickerOpenRequestGuard.target( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: selectedAgent, + codexEffortsAvailable: codexEffortsAvailable, + claudeEffortsAvailable: claudeEffortsAvailable + ) + } +} diff --git a/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift new file mode 100644 index 000000000..e4be5ed4e --- /dev/null +++ b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift @@ -0,0 +1,34 @@ +import KeyboardShortcuts +@testable import RepoPromptApp +import XCTest + +final class KeyboardShortcutCatalogTests: XCTestCase { + func testAgentLayoutCatalogContainsAgentModelPickerShortcut() throws { + let section = try agentLayoutSection() + + let binding = try XCTUnwrap( + section.bindings.first(where: { $0.id == "agent-model-picker" }) + ) + + XCTAssertEqual(binding.title, "Open Agent/Model picker") + XCTAssertEqual(binding.name, KeyboardShortcuts.Name.showAgentModelPicker) + } + + func testAgentLayoutCatalogContainsUnboundAgentEffortPickerShortcut() throws { + let section = try agentLayoutSection() + + let binding = try XCTUnwrap( + section.bindings.first(where: { $0.id == "agent-effort-picker" }) + ) + + XCTAssertEqual(binding.title, "Open Agent effort picker") + XCTAssertEqual(binding.name, KeyboardShortcuts.Name.showAgentEffortPicker) + XCTAssertNil(binding.name.defaultShortcut) + } + + private func agentLayoutSection() throws -> KeyboardShortcutCatalogSection { + try XCTUnwrap( + KeyboardShortcutCatalog.sections.first(where: { $0.id == "agent-layout" }) + ) + } +} diff --git a/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift new file mode 100644 index 000000000..f0aa1b19b --- /dev/null +++ b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift @@ -0,0 +1,250 @@ +import AppKit +@testable import RepoPromptApp +import XCTest + +@MainActor +final class StableMenuPresenterTests: XCTestCase { + func testRepeatedProgrammaticRequestsPresentTwice() async { + var openCount = 0 + var itemCount = 0 + var presentationCount = 0 + let presenter = StableMenuPresenter { _, _, _ in + presentationCount += 1 + } + let fixture = makeAttachedAnchor() + update( + presenter, + anchor: fixture.anchor, + onOpen: { openCount += 1 }, + items: { + itemCount += 1 + return [.action("Item") {}] + } + ) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + await drainMainQueue() + + XCTAssertEqual(openCount, 2) + XCTAssertEqual(itemCount, 2) + XCTAssertEqual(presentationCount, 2) + } + + func testDeferredProgrammaticRequestUsesLatestPreparationClosures() async { + var oldOpenCount = 0 + var oldItemCount = 0 + var newOpenCount = 0 + var newItemCount = 0 + var presentedTitle: String? + let presenter = StableMenuPresenter { menu, _, _ in + presentedTitle = menu.items.first?.title + } + let fixture = makeAttachedAnchor() + update( + presenter, + anchor: fixture.anchor, + onOpen: { oldOpenCount += 1 }, + items: { + oldItemCount += 1 + return [.action("Old") {}] + } + ) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, + onOpen: { newOpenCount += 1 }, + items: { + newItemCount += 1 + return [.action("New") {}] + } + ) + await drainMainQueue() + + XCTAssertEqual(oldOpenCount, 0) + XCTAssertEqual(oldItemCount, 0) + XCTAssertEqual(newOpenCount, 1) + XCTAssertEqual(newItemCount, 1) + XCTAssertEqual(presentedTitle, "New") + } + + func testDeferredProgrammaticRequestDoesNotOpenWhenSwiftUIControlBecomesDisabled() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Disabled control must not present a menu") + let fixture = makeAttachedAnchor() + update(presenter, anchor: fixture.anchor, counters: counters) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, + isEnabled: false, + isProgrammaticPresentationAllowed: true, + counters: counters + ) + await drainMainQueue() + + assertPreparationWasSkipped(counters) + } + + func testDeferredProgrammaticRequestDoesNotOpenWhenLiveApplicabilityBecomesFalse() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Inapplicable request must not present a menu") + let fixture = makeAttachedAnchor() + update(presenter, anchor: fixture.anchor, counters: counters) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, + isEnabled: true, + isProgrammaticPresentationAllowed: false, + counters: counters + ) + await drainMainQueue() + + assertPreparationWasSkipped(counters) + } + + func testDeferredProgrammaticRequestDoesNotOpenAfterAnchorDetaches() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Detached control must not present a menu") + let fixture = makeAttachedAnchor() + update(presenter, anchor: fixture.anchor, counters: counters) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + fixture.anchor.removeFromSuperview() + await drainMainQueue() + + assertPreparationWasSkipped(counters) + } + + func testDeferredProgrammaticRequestDoesNotOpenForStalePresentationIdentity() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Stale request must not present a menu") + let fixture = makeAttachedAnchor() + update( + presenter, + anchor: fixture.anchor, + programmaticPresentationID: "old-tab-or-provider", + counters: counters + ) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "old-tab-or-provider") + update( + presenter, + anchor: fixture.anchor, + programmaticPresentationID: "new-tab-or-provider", + counters: counters + ) + await drainMainQueue() + + assertPreparationWasSkipped(counters) + } + + func testOrdinaryClickPresentationDoesNotRequireProgrammaticPermission() { + var didOpen = false + var didPresent = false + let presenter = StableMenuPresenter { _, _, _ in + didPresent = true + } + let fixture = makeAttachedAnchor() + update( + presenter, + anchor: fixture.anchor, + isProgrammaticPresentationAllowed: false, + onOpen: {}, + items: { [.action("Current") {}] } + ) + + presenter.present( + expectedProgrammaticPresentationID: nil, + requiresProgrammaticPermission: false, + onOpen: { didOpen = true }, + items: { [.action("Item") {}] } + ) + + XCTAssertTrue(didOpen) + XCTAssertTrue(didPresent) + } + + private func update( + _ presenter: StableMenuPresenter, + anchor: NSView, + isEnabled: Bool = true, + programmaticPresentationID: AnyHashable = "current", + isProgrammaticPresentationAllowed: Bool = true, + onOpen: @escaping () -> Void, + items: @escaping () -> [StableMenuItem] + ) { + presenter.update( + anchorView: anchor, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: onOpen, + items: items + ) + } + + private func update( + _ presenter: StableMenuPresenter, + anchor: NSView, + isEnabled: Bool = true, + programmaticPresentationID: AnyHashable = "current", + isProgrammaticPresentationAllowed: Bool = true, + counters: PreparationCounters + ) { + update( + presenter, + anchor: anchor, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: { counters.openCount += 1 }, + items: { + counters.itemCount += 1 + return [.action("Item") {}] + } + ) + } + + private func rejectingPresenter(message: String) -> StableMenuPresenter { + StableMenuPresenter { _, _, _ in + XCTFail(message) + } + } + + private func assertPreparationWasSkipped(_ counters: PreparationCounters) { + XCTAssertEqual(counters.openCount, 0) + XCTAssertEqual(counters.itemCount, 0) + } + + private func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + + private func makeAttachedAnchor() -> (window: NSWindow, anchor: NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + window.contentView?.addSubview(anchor) + return (window, anchor) + } +} + +@MainActor +private final class PreparationCounters { + var openCount = 0 + var itemCount = 0 +}