diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index b35ed565..712da87e 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -208,6 +208,7 @@ BB6325CA50F97B18B9725918 /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */; }; BBAD4EC8845DD705F1A60FE5 /* TerminalCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4BB60E9C750254B959F7D0 /* TerminalCompletionPromptRendererTests.swift */; }; BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; + BD14033738B08B4289FFB88D /* ReloadFocusKeybindTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7603160CFFE58D86CAAD2691 /* ReloadFocusKeybindTests.swift */; }; BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; @@ -424,6 +425,7 @@ 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRunTests.swift; sourceTree = ""; }; 7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverride.swift; sourceTree = ""; }; + 7603160CFFE58D86CAAD2691 /* ReloadFocusKeybindTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadFocusKeybindTests.swift; sourceTree = ""; }; 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = ""; }; 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = ""; }; 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; @@ -875,6 +877,7 @@ 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */, 4696A84D17890B154533A08F /* PromptPolicyTests.swift */, E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */, + 7603160CFFE58D86CAAD2691 /* ReloadFocusKeybindTests.swift */, B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */, 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, @@ -1419,6 +1422,7 @@ 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */, 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */, 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */, + BD14033738B08B4289FFB88D /* ReloadFocusKeybindTests.swift in Sources */, 1B3FFCB9A979F49BF86EAAD4 /* ScreenshotContextGeneratorTests.swift in Sources */, 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 2cb81d60..e49c3db0 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -42,6 +42,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let activationIndicatorController: ActivationIndicatorController private let focusDebugOverlayController: FocusDebugOverlayController? private var cancellables = Set() + /// Token for the app-activation observer that re-reads focus on app switch, so the + /// editor-protection guards don't keep serving a stale cached snapshot after the user switches + /// into a terminal/editor. Owned here (app-lifetime) rather than on the transient environment. + private var appActivationObserver: NSObjectProtocol? private var didStartServices = false override init() { @@ -124,6 +128,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) + // Reload-focus hotkey: pressing it forces a focus re-detection. Wired on the app delegate + // (not the transient environment) so the `[weak self]` capture stays valid for the session. + inputMonitor.onReloadFocusHotkey = { [weak self] in + self?.reloadFocusDetection() + } + + // Install/uninstall the reload-focus tap when the user binds or clears the hotkey, so a + // user who never sets it pays no per-keystroke cost. + suggestionSettings.$reloadFocusKeyCode + .removeDuplicates() + .sink { [weak self] _ in + self?.inputMonitor.refreshReloadFocusTap() + } + .store(in: &cancellables) + + // Re-read focus on app activation. Switching INTO an app (a terminal/editor) can leave a + // stale focus snapshot — the poll timer may not have re-read yet and the editor-protection + // guards key off the cached snapshot — so a fresh read here lets the correct source pick up + // without a manual reload. FocusTracker debounces by element identity, so this is cheap. + appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.focusModel.refreshNow() + } + } } /// Starts runtime and polling services once AppKit reports that app launch finished. @@ -149,6 +181,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { CotabbyLogger.app.info("All services started") } + /// Force a full focus re-detection: discard stale terminal/TUI injections and cached AX state, + /// prune dead shell sessions, drop prompt anchors, re-read the focused element, and re-evaluate + /// availability. The manual escape hatch (reload hotkey + menu-bar item) for when Cotabby has + /// latched onto the wrong source — e.g. an editor AX field instead of the shell/TUI. + func reloadFocusDetection() { + CotabbyLogger.app.info("Reload focus requested — clearing injected state and re-detecting") + // Drop sessions for shells that have died (e.g. an app restart left a stale socket). + terminalIntegrationService.pruneStaleSessionsIfNeeded() + // Cancel any in-flight OCR and release a TUI-owned injection. + tuiContextCoordinator.cancelPending() + // Stop suppressing AX polls and republish the real focused-element snapshot. + focusModel.clearTerminalInjection() + // Drop stale prompt anchors so the next shell report re-OCRs geometry. + shellPromptGeometryCoordinator.invalidateAll() + // Force an immediate fresh AX read of whatever is focused right now. + focusModel.refreshNow() + // Re-evaluate whether suggestions should be active for the freshly-read environment. + suggestionCoordinator.reconcileWithCurrentEnvironment() + } + /// Synchronously releases native runtime resources before AppKit calls `exit()`. /// /// `exit()` runs C++ static destructors that tear down the Metal device. If llama contexts diff --git a/Cotabby/App/Core/CotabbyApp.swift b/Cotabby/App/Core/CotabbyApp.swift index 2bbf2666..d564d430 100644 --- a/Cotabby/App/Core/CotabbyApp.swift +++ b/Cotabby/App/Core/CotabbyApp.swift @@ -33,6 +33,9 @@ struct CotabbyApp: App { // (Cotabby + macOS + hardware) and the user only has to write the actual report. let url = DeviceInfo.snapshot().appending(to: baseURL) NSWorkspace.shared.open(url) + }, + onReloadFocus: { + appDelegate.reloadFocusDetection() } ) } label: { diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 9f66b1d9..eeff5593 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -70,6 +70,10 @@ final class CotabbyAppEnvironment { inputMonitor.onGlobalToggleHotkey = { [weak suggestionSettings] in suggestionSettings?.toggleGloballyEnabled() } + // Reload-focus hotkey: providers read live from the model; the callback (which needs every + // terminal service) is wired below, once those services exist. + inputMonitor.reloadFocusKeyCodeProvider = { suggestionSettings.reloadFocusKeyCode } + inputMonitor.reloadFocusKeyModifiersProvider = { suggestionSettings.reloadFocusKeyModifiers } // Stop the deep AX walk when Cotabby is disabled for the focused app. Without this the // focus poll keeps enumerating the frontmost app's AX attributes every 50-80ms even after // the user toggles Cotabby off, which can dismiss transient popovers in apps like Calendar diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 1d792825..7b8d4904 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -12,6 +12,9 @@ enum ShortcutAction: CaseIterable, Equatable, Hashable { /// TUIs like Claude Code running inside them). A separate binding from `acceptWord` because /// Tab — the natural global accept — belongs to shell completion inside a terminal. case terminalAccept + /// Global hotkey that forces Cotabby to re-detect the focused field's source (AX text field + /// vs terminal shell prompt vs Claude Code TUI), clearing any stale injected/cached state. + case reloadFocus var displayName: String { switch self { @@ -19,6 +22,7 @@ enum ShortcutAction: CaseIterable, Equatable, Hashable { case .acceptEntireSuggestion: return "Accept Entire Suggestion" case .toggleTabby: return "Toggle Tabby" case .terminalAccept: return "Terminal Accept" + case .reloadFocus: return "Re-detect Focus" } } } @@ -81,6 +85,11 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var globalToggleKeyCode: CGKeyCode @Published private(set) var globalToggleKeyModifiers: ShortcutModifierMask @Published private(set) var globalToggleKeyLabel: String + /// User-configurable hotkey that forces a focus re-detection (clears stale terminal/TUI + /// injections and re-reads the focused element). Defaults to unbound; opt-in like the toggle. + @Published private(set) var reloadFocusKeyCode: CGKeyCode + @Published private(set) var reloadFocusKeyModifiers: ShortcutModifierMask + @Published private(set) var reloadFocusKeyLabel: String @Published private(set) var acceptanceGranularity: AcceptanceGranularity /// Whether the shell-integration-based terminal autocomplete subsystem is active. @Published private(set) var isTerminalIntegrationEnabled: Bool @@ -141,6 +150,9 @@ final class SuggestionSettingsModel: ObservableObject { private static let globalToggleKeyCodeDefaultsKey = "cotabbyGlobalToggleKeyCode" private static let globalToggleKeyModifiersDefaultsKey = "cotabbyGlobalToggleKeyModifiers" private static let globalToggleKeyLabelDefaultsKey = "cotabbyGlobalToggleKeyLabel" + private static let reloadFocusKeyCodeDefaultsKey = "cotabbyReloadFocusKeyCode" + private static let reloadFocusKeyModifiersDefaultsKey = "cotabbyReloadFocusKeyModifiers" + private static let reloadFocusKeyLabelDefaultsKey = "cotabbyReloadFocusKeyLabel" private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity" private static let terminalIntegrationEnabledDefaultsKey = "cotabbyTerminalIntegrationEnabled" private static let terminalAcceptanceKeyCodeDefaultsKey = "cotabbyTerminalAcceptanceKeyCode" @@ -335,6 +347,16 @@ final class SuggestionSettingsModel: ObservableObject { ) let resolvedGlobalToggleKeyLabel = userDefaults.string(forKey: Self.globalToggleKeyLabelDefaultsKey) ?? Self.disabledKeyLabel + // Reload-focus hotkey: same opt-in, unbound-by-default contract as the global toggle. + let resolvedReloadFocusKeyCode = CGKeyCode( + userDefaults.object(forKey: Self.reloadFocusKeyCodeDefaultsKey) as? Int + ?? Int(Self.disabledKeyCode) + ) + let resolvedReloadFocusKeyModifiers = ShortcutModifierMask( + rawValue: UInt32(userDefaults.object(forKey: Self.reloadFocusKeyModifiersDefaultsKey) as? Int ?? 0) + ) + let resolvedReloadFocusKeyLabel = userDefaults.string(forKey: Self.reloadFocusKeyLabelDefaultsKey) + ?? Self.disabledKeyLabel // Default `.word` preserves the pre-feature behavior for existing installs that have no // value persisted yet. Invalid persisted values fall back to `.word` rather than crashing // so a hand-edited UserDefault can't strand the user. @@ -397,6 +419,9 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyCode = resolvedGlobalToggleKeyCode globalToggleKeyModifiers = resolvedGlobalToggleKeyModifiers globalToggleKeyLabel = resolvedGlobalToggleKeyLabel + reloadFocusKeyCode = resolvedReloadFocusKeyCode + reloadFocusKeyModifiers = resolvedReloadFocusKeyModifiers + reloadFocusKeyLabel = resolvedReloadFocusKeyLabel acceptanceGranularity = resolvedAcceptanceGranularity isTerminalIntegrationEnabled = resolvedTerminalIntegrationEnabled terminalAcceptanceKeyCode = resolvedTerminalAcceptanceKeyCode @@ -444,6 +469,12 @@ final class SuggestionSettingsModel: ObservableObject { forKey: Self.globalToggleKeyModifiersDefaultsKey ) userDefaults.set(resolvedGlobalToggleKeyLabel, forKey: Self.globalToggleKeyLabelDefaultsKey) + userDefaults.set(Int(resolvedReloadFocusKeyCode), forKey: Self.reloadFocusKeyCodeDefaultsKey) + userDefaults.set( + Int(resolvedReloadFocusKeyModifiers.rawValue), + forKey: Self.reloadFocusKeyModifiersDefaultsKey + ) + userDefaults.set(resolvedReloadFocusKeyLabel, forKey: Self.reloadFocusKeyLabelDefaultsKey) userDefaults.set(resolvedTerminalIntegrationEnabled, forKey: Self.terminalIntegrationEnabledDefaultsKey) userDefaults.set(Int(resolvedTerminalAcceptanceKeyCode), forKey: Self.terminalAcceptanceKeyCodeDefaultsKey) userDefaults.set( @@ -975,6 +1006,27 @@ final class SuggestionSettingsModel: ObservableObject { setGlobalToggleKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } + func setReloadFocusKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { + let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers + guard reloadFocusKeyCode != keyCode + || reloadFocusKeyModifiers != normalizedModifiers + || reloadFocusKeyLabel != label + else { + return + } + + reloadFocusKeyCode = keyCode + reloadFocusKeyModifiers = normalizedModifiers + reloadFocusKeyLabel = label + userDefaults.set(Int(keyCode), forKey: Self.reloadFocusKeyCodeDefaultsKey) + userDefaults.set(Int(normalizedModifiers.rawValue), forKey: Self.reloadFocusKeyModifiersDefaultsKey) + userDefaults.set(label, forKey: Self.reloadFocusKeyLabelDefaultsKey) + } + + func clearReloadFocusKey() { + setReloadFocusKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) + } + func setTerminalIntegrationEnabled(_ enabled: Bool) { guard isTerminalIntegrationEnabled != enabled else { return } isTerminalIntegrationEnabled = enabled @@ -1211,6 +1263,9 @@ final class SuggestionSettingsModel: ObservableObject { if terminalAcceptanceKeyCode == keyCode, terminalAcceptanceKeyModifiers == modifiers { return "Terminal Accept" } + if reloadFocusKeyCode == keyCode, reloadFocusKeyModifiers == modifiers { + return ShortcutAction.reloadFocus.displayName + } return nil } @@ -1224,6 +1279,8 @@ final class SuggestionSettingsModel: ObservableObject { return (globalToggleKeyCode, globalToggleKeyModifiers) case .terminalAccept: return (terminalAcceptanceKeyCode, terminalAcceptanceKeyModifiers) + case .reloadFocus: + return (reloadFocusKeyCode, reloadFocusKeyModifiers) } } diff --git a/Cotabby/Services/Input/InputMonitor.swift b/Cotabby/Services/Input/InputMonitor.swift index c384892b..9442d363 100644 --- a/Cotabby/Services/Input/InputMonitor.swift +++ b/Cotabby/Services/Input/InputMonitor.swift @@ -88,6 +88,18 @@ final class InputMonitor { /// `isGloballyEnabled` setting; the keystroke is then consumed so the host app never sees it. var onGlobalToggleHotkey: (@MainActor () -> Void)? + /// Reads the reload-focus hotkey at event time. `disabledKeyCode` (UInt16.max) means unbound; + /// the dedicated tap is torn down whenever the provider returns that sentinel, so an unbound + /// hotkey costs nothing per keystroke. + var reloadFocusKeyCodeProvider: @MainActor () -> CGKeyCode = { CGKeyCode(UInt16.max) } + + /// Modifier mask required alongside the reload-focus hotkey. Empty means the bare key. + var reloadFocusKeyModifiersProvider: @MainActor () -> ShortcutModifierMask = { [] } + + /// Fired when a key event matches the configured reload-focus hotkey. Wired to force a focus + /// re-detection; the keystroke is then consumed so the host app never sees it. + var onReloadFocusHotkey: (@MainActor () -> Void)? + /// When false, the observer passes keystrokes through without classifying or notifying the /// coordinator. This eliminates per-keystroke overhead in apps where Cotabby will never act /// (terminals, globally disabled, per-app disabled). @@ -117,6 +129,11 @@ final class InputMonitor { private var toggleTap: CFMachPort? private var toggleRunLoopSource: CFRunLoopSource? + /// Dedicated consuming tap for the reload-focus hotkey. Independent of the other taps (like the + /// toggle tap) because it must fire even when no suggestion is visible. + private var reloadFocusTap: CFMachPort? + private var reloadFocusRunLoopSource: CFRunLoopSource? + /// Tracks whether the consuming tap currently owns accept-key semantics. This is separate /// from "a suggestion exists": the observer should only suppress accept-key callbacks when a /// real default tap can make the consume/pass-through decision for that same physical key. @@ -154,6 +171,7 @@ final class InputMonitor { CotabbyLogger.app.info("Input monitor stopping") destroyAcceptTap() destroyToggleTap() + destroyReloadFocusTap() destroyObserverTap() } @@ -164,9 +182,11 @@ final class InputMonitor { if permissionProvider() { installObserverTapIfNeeded() refreshToggleTap() + refreshReloadFocusTap() } else { destroyAcceptTap() destroyToggleTap() + destroyReloadFocusTap() destroyObserverTap() } } @@ -186,6 +206,21 @@ final class InputMonitor { } } + /// Installs or removes the reload-focus tap to match the current binding. Mirror of + /// `refreshToggleTap()`; called by the environment whenever the user changes or clears the + /// reload hotkey so the tap's lifetime tracks "binding exists". + func refreshReloadFocusTap() { + guard permissionProvider() else { + destroyReloadFocusTap() + return + } + if reloadFocusKeyCodeProvider() == Self.disabledKeyCode { + destroyReloadFocusTap() + } else { + installReloadFocusTapIfNeeded() + } + } + private static let disabledKeyCode: CGKeyCode = CGKeyCode(UInt16.max) /// How long the accept tap lingers (fail-open) after the overlay hides before its mach port is @@ -375,6 +410,49 @@ final class InputMonitor { CGEvent.tapEnable(tap: tap, enable: true) } + private func installReloadFocusTapIfNeeded() { + guard reloadFocusTap == nil else { + return + } + + let mask = (1 << CGEventType.keyDown.rawValue) + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { + return Unmanaged.passUnretained(event) + } + + let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return MainActor.assumeIsolated { + monitor.handleReloadFocusTap(type: type, event: event) + } + } + + // Head-inserted, like the toggle tap: the callback returns `nil` only on a bound-hotkey + // match, so unrelated keys drain through to the rest of the chain unchanged. + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(mask), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + CotabbyLogger.app.warning("Failed to create CGEvent reload-focus tap") + return + } + CotabbyLogger.app.info("CGEvent reload-focus tap installed (active, reload-hotkey only)") + + reloadFocusTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + reloadFocusRunLoopSource = source + + if let source { + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + } + + CGEvent.tapEnable(tap: tap, enable: true) + } + private func destroyObserverTap() { if let source = observerRunLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) @@ -420,6 +498,22 @@ final class InputMonitor { CotabbyLogger.app.info("CGEvent toggle tap removed") } + private func destroyReloadFocusTap() { + guard reloadFocusTap != nil || reloadFocusRunLoopSource != nil else { + return + } + if let source = reloadFocusRunLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + reloadFocusRunLoopSource = nil + + if let tap = reloadFocusTap { + CFMachPortInvalidate(tap) + } + reloadFocusTap = nil + CotabbyLogger.app.info("CGEvent reload-focus tap removed") + } + /// Active toggle tap: consumes a keystroke only when it matches the configured global-toggle /// hotkey. The match is intentionally evaluated against the providers (not a cached snapshot) /// so a settings change is picked up on the very next keystroke. Runs independently of @@ -459,6 +553,44 @@ final class InputMonitor { } } + /// Active reload-focus tap: consumes a keystroke only when it matches the configured reload + /// hotkey, then fires `onReloadFocusHotkey`. Evaluated against the providers (not a cached + /// snapshot) so a settings change is picked up on the next keystroke. Mirror of + /// `handleToggleTap`. + func handleReloadFocusTap(type: CGEventType, event: CGEvent) -> Unmanaged? { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + CotabbyLogger.app.warning("Reload-focus tap was disabled by system, re-enabling") + if let reloadFocusTap { + CGEvent.tapEnable(tap: reloadFocusTap, enable: true) + } + return Unmanaged.passUnretained(event) + + case .keyDown: + if suppressionController.isSynthetic(event) { + return Unmanaged.passUnretained(event) + } + + let bound = reloadFocusKeyCodeProvider() + guard bound != Self.disabledKeyCode else { + return Unmanaged.passUnretained(event) + } + + let keyCode = keyCode(from: event) + let modifiers = ShortcutModifierMask(eventFlags: event.flags) + guard keyCode == bound, modifiers == reloadFocusKeyModifiersProvider() else { + return Unmanaged.passUnretained(event) + } + + onReloadFocusHotkey?() + CotabbyLogger.app.debug("Reload-focus tap consumed keyCode=\(keyCode) modifiers=\(modifiers.rawValue)") + return nil + + default: + return Unmanaged.passUnretained(event) + } + } + /// Listen-only observer: classifies the event and notifies the coordinator. The return value /// of `onEvent` is ignored here because a listen-only tap cannot drop or modify events. /// Consumption of the accept key is handled by the separate active accept tap. diff --git a/Cotabby/UI/MenuBarView.swift b/Cotabby/UI/MenuBarView.swift index 687f77ce..79491ac9 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -23,6 +23,9 @@ struct MenuBarView: View { let appUpdateManager: AppUpdateManager let onOpenSettings: () -> Void let onReportFeedback: () -> Void + /// Forces a focus re-detection (clears stale terminal/TUI injections and re-reads focus). Same + /// action as the optional reload hotkey, surfaced here so it's reachable without binding a key. + let onReloadFocus: () -> Void /// Captures the popover's host window so `Button` actions that open another window can dismiss /// the popover behind them. SwiftUI's `\.dismiss` does not work for `MenuBarExtra(.window)`. @@ -104,6 +107,17 @@ struct MenuBarView: View { .toggleStyle(.switch) .controlSize(.small) } + + // Manual escape hatch when Cotabby latched onto the wrong source (e.g. an editor AX + // field instead of the shell/TUI). Same action as the optional reload hotkey. + Button { + onReloadFocus() + } label: { + Label("Re-detect Focus", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderless) + .controlSize(.small) + .help("Recheck the focused field's source if Cotabby picked the wrong one (terminal/TUI vs text field).") } Divider() diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index 035aa471..ee285974 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -274,6 +274,9 @@ struct AppsPaneView: View { case .terminalAccept: // Shell accept is a global shell-surface binding; per-app overrides do not carry it. return suggestionSettings.terminalAcceptanceKeyLabel + case .reloadFocus: + // Reload-focus is global-only too; per-app overrides do not carry it. + return suggestionSettings.reloadFocusKeyLabel } } @@ -287,6 +290,8 @@ struct AppsPaneView: View { return suggestionSettings.globalToggleKeyCode case .terminalAccept: return suggestionSettings.terminalAcceptanceKeyCode + case .reloadFocus: + return suggestionSettings.reloadFocusKeyCode } } @@ -303,6 +308,8 @@ struct AppsPaneView: View { return suggestionSettings.globalToggleKeyModifiers case .terminalAccept: return suggestionSettings.terminalAcceptanceKeyModifiers + case .reloadFocus: + return suggestionSettings.reloadFocusKeyModifiers } } @@ -337,6 +344,9 @@ struct AppsPaneView: View { case .terminalAccept: // Shell accept is likewise global-only: it follows the shell surface, not the app. break + case .reloadFocus: + // Reload-focus is global-only: there is no per-app reload binding. + break } } @@ -350,6 +360,8 @@ struct AppsPaneView: View { break case .terminalAccept: break + case .reloadFocus: + break } } diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index eb397950..2b8c6782 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -10,6 +10,7 @@ struct ShortcutsPaneView: View { @State private var isRecordingFullAcceptKeybind = false @State private var isRecordingGlobalToggleKeybind = false @State private var isRecordingTerminalAcceptKeybind = false + @State private var isRecordingReloadFocusKeybind = false var body: some View { SettingsPaneScaffold { @@ -166,6 +167,39 @@ struct ShortcutsPaneView: View { description: "Turn Cotabby on or off globally without opening the menu bar." ) } + + // Opt-in like the toggle above: no factory default, so `onReset: nil` hides Reset. + LabeledContent { + KeybindRow( + label: suggestionSettings.reloadFocusKeyLabel, + keyCode: suggestionSettings.reloadFocusKeyCode, + modifiers: suggestionSettings.reloadFocusKeyModifiers, + defaultKeyCode: SuggestionSettingsModel.disabledKeyCode, + isRecording: $isRecordingReloadFocusKeybind, + onRecord: { keyCode, modifiers, label in + suggestionSettings.setReloadFocusKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: nil, + onClear: { suggestionSettings.clearReloadFocusKey() }, + clearHelp: "Unbind this shortcut. No key will force a focus re-detection.", + conflictChecker: { keyCode, modifiers in + suggestionSettings.conflictingShortcutName( + keyCode: keyCode, + modifiers: modifiers, + excluding: .reloadFocus + ) + } + ) + } label: { + SettingsRowLabel( + title: "Re-detect Focus", + description: "Recheck if the focused field is a terminal/TUI vs a text field, when Cotabby picks wrong." + ) + } } } } diff --git a/CotabbyTests/ReloadFocusKeybindTests.swift b/CotabbyTests/ReloadFocusKeybindTests.swift new file mode 100644 index 00000000..dc51ba0a --- /dev/null +++ b/CotabbyTests/ReloadFocusKeybindTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Cotabby + +/// Locks down the reload-focus hotkey's storage contract: it defaults to unbound (opt-in, like the +/// global toggle), persists across model instances, and clears back to the disabled sentinel. +@MainActor +final class ReloadFocusKeybindTests: XCTestCase { + + /// A fresh install has no reload hotkey bound — the feature is opt-in. + func test_default_isUnbound() { + let (model, _) = makeModel() + XCTAssertEqual(model.reloadFocusKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertEqual(model.reloadFocusKeyLabel, SuggestionSettingsModel.disabledKeyLabel) + XCTAssertTrue(model.reloadFocusKeyModifiers.isEmpty) + } + + /// Setting the hotkey persists to UserDefaults, so a fresh model (same defaults) loads it back. + func test_setReloadFocusKey_persistsAcrossInstances() { + let (model, defaults) = makeModel() + model.setReloadFocusKey(keyCode: 49, modifiers: [.command, .shift], label: "⌘⇧Space") + + XCTAssertEqual(model.reloadFocusKeyCode, 49) + XCTAssertEqual(model.reloadFocusKeyModifiers, [.command, .shift]) + XCTAssertEqual(model.reloadFocusKeyLabel, "⌘⇧Space") + + let reloaded = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + XCTAssertEqual(reloaded.reloadFocusKeyCode, 49) + XCTAssertEqual(reloaded.reloadFocusKeyModifiers, [.command, .shift]) + XCTAssertEqual(reloaded.reloadFocusKeyLabel, "⌘⇧Space") + } + + /// Clearing unbinds the hotkey (disabled sentinel + empty modifiers), and that persists too. + func test_clearReloadFocusKey_unbinds() { + let (model, defaults) = makeModel() + model.setReloadFocusKey(keyCode: 49, modifiers: [.command], label: "⌘Space") + model.clearReloadFocusKey() + + XCTAssertEqual(model.reloadFocusKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertTrue(model.reloadFocusKeyModifiers.isEmpty) + + let reloaded = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + XCTAssertEqual(reloaded.reloadFocusKeyCode, SuggestionSettingsModel.disabledKeyCode) + } + + private func makeModel() -> (SuggestionSettingsModel, UserDefaults) { + let suiteName = "cotabby.test.reloadFocusKeybind.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return (SuggestionSettingsModel(configuration: .standard, userDefaults: defaults), defaults) + } +} diff --git a/CotabbyTests/ShortcutConflictTests.swift b/CotabbyTests/ShortcutConflictTests.swift index 79dde1b3..08f8fad3 100644 --- a/CotabbyTests/ShortcutConflictTests.swift +++ b/CotabbyTests/ShortcutConflictTests.swift @@ -71,6 +71,31 @@ final class ShortcutConflictTests: XCTestCase { XCTAssertNil(conflict) } + /// The reload-focus hotkey participates in conflict detection like every other action: a combo + /// it owns is reported against any other action being edited. + func test_conflict_flagsReloadFocusCollision() { + let model = makeModel() + model.setReloadFocusKey(keyCode: 49, modifiers: [], label: "Space") + let conflict = model.conflictingShortcutName( + keyCode: 49, + modifiers: [], + excluding: .toggleTabby + ) + XCTAssertEqual(conflict, "Re-detect Focus") + } + + /// Re-recording the reload-focus hotkey to its own current value is not a self-collision. + func test_conflict_excludesReloadFocusBeingEdited() { + let model = makeModel() + model.setReloadFocusKey(keyCode: 49, modifiers: [], label: "Space") + let conflict = model.conflictingShortcutName( + keyCode: 49, + modifiers: [], + excluding: .reloadFocus + ) + XCTAssertNil(conflict) + } + // MARK: - Per-app override scoping /// Per-app conflict scoping: two different apps can bind the same combo without conflict, @@ -138,6 +163,20 @@ final class ShortcutConflictTests: XCTestCase { XCTAssertEqual(conflict, "Terminal Accept") } + /// The reload-focus hotkey is app-spanning too (head-inserted global tap), so a per-app accept + /// that collides with it would be eaten by the reload tap. Refuse the combo up front. + func test_perAppConflict_flagsReloadFocusCollision() { + let model = makeModel() + model.setReloadFocusKey(keyCode: 49, modifiers: [.command, .shift], label: "⌘⇧Space") + let conflict = model.conflictingPerAppShortcutName( + forBundleIdentifier: "com.apple.notes", + keyCode: 49, + modifiers: [.command, .shift], + excluding: .acceptWord + ) + XCTAssertEqual(conflict, "Re-detect Focus") + } + /// Re-recording the same action on the same app must not be flagged as a self-collision. func test_perAppConflict_allowsRebindingSameActionToSameKey() { let model = makeModel()