Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -424,6 +425,7 @@
74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = "<group>"; };
75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRunTests.swift; sourceTree = "<group>"; };
7599A7A2B95DF7F6BD4363C7 /* PerAppShortcutOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverride.swift; sourceTree = "<group>"; };
7603160CFFE58D86CAAD2691 /* ReloadFocusKeybindTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadFocusKeybindTests.swift; sourceTree = "<group>"; };
764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = "<group>"; };
77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = "<group>"; };
78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
52 changes: 52 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let activationIndicatorController: ActivationIndicatorController
private let focusDebugOverlayController: FocusDebugOverlayController?
private var cancellables = Set<AnyCancellable>()
/// 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() {
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cotabby/App/Core/CotabbyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ 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 {
case .acceptWord: return "Accept Word"
case .acceptEntireSuggestion: return "Accept Entire Suggestion"
case .toggleTabby: return "Toggle Tabby"
case .terminalAccept: return "Terminal Accept"
case .reloadFocus: return "Re-detect Focus"
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -1224,6 +1279,8 @@ final class SuggestionSettingsModel: ObservableObject {
return (globalToggleKeyCode, globalToggleKeyModifiers)
case .terminalAccept:
return (terminalAcceptanceKeyCode, terminalAcceptanceKeyModifiers)
case .reloadFocus:
return (reloadFocusKeyCode, reloadFocusKeyModifiers)
}
}

Expand Down
Loading