From a8d412af8defe99ff25b970a5b46ea87054d6e20 Mon Sep 17 00:00:00 2001 From: Victor Williams Date: Sun, 9 Aug 2026 23:10:39 -0400 Subject: [PATCH 1/3] Auto-disable suggestions when Low Power Mode is on (#760) Cotabby's local models keep generating suggestions even when macOS Low Power Mode is active, working against the OS's own battery-saving throttling. Add a LowPowerModeMonitor (mirroring the existing PowerSourceMonitor) that tracks ProcessInfo.isLowPowerModeEnabled live, and gate it through SuggestionAvailabilityEvaluator alongside the existing globally-enabled/paused checks so predictions and visual context both pause automatically. The behavior is on by default (matching the request) with a new "Pause in Low Power Mode" toggle in Settings > Engine & Model > Power for users who want autocomplete regardless of battery impact. --- Cotabby.xcodeproj/project.pbxproj | 6 ++ .../SuggestionCoordinator+Input.swift | 27 ++++++ .../SuggestionCoordinator+Lifecycle.swift | 4 + .../SuggestionCoordinator+Prediction.swift | 2 + .../Suggestion/SuggestionCoordinator.swift | 12 +++ Cotabby/App/Core/CotabbyAppEnvironment.swift | 7 ++ .../Settings/SuggestionSettingsData.swift | 9 ++ .../Settings/SuggestionSettingsModel.swift | 36 +++++-- .../Suggestion/SuggestionEngineModels.swift | 4 + .../SuggestionSubsystemContracts.swift | 9 ++ .../Services/Power/LowPowerModeMonitor.swift | 53 +++++++++++ .../Settings/SuggestionSettingsStore.swift | 15 ++- .../SuggestionAvailabilityEvaluator.swift | 94 ++++++++++++++----- .../Engine/EngineAndModelPaneView+Power.swift | 15 +++ Cotabby/UI/Settings/SettingsIndex.swift | 9 +- ...SuggestionCoordinatorAcceptanceTests.swift | 12 +++ .../SuggestionCoordinatorInputTests.swift | 26 +++++ .../SuggestionCoordinatorLifecycleTests.swift | 19 ++++ .../SuggestionSettingsDomainTests.swift | 2 + .../SuggestionSettingsModelTests.swift | 11 +++ .../SuggestionSettingsStoreTests.swift | 13 +++ ...SuggestionAvailabilityEvaluatorTests.swift | 82 ++++++++++++++++ .../TestSupport/CotabbyTestFixtures.swift | 2 + .../SuggestionCoordinatorTestSupport.swift | 15 +++ 24 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 Cotabby/Services/Power/LowPowerModeMonitor.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 81d6b9f1..2ae55f63 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 0144ECDA06A8BDEFBB8DACE3 /* RuntimeBootstrapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF8AA184C5F9CFFDF97A070 /* RuntimeBootstrapModel.swift */; }; 01945ABF02DEFD8FE470C3F3 /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */; }; 019EFBC6CD5189676D8B035F /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */; }; + 01D6FF389B0E14C5BFDE4FBB /* LowPowerModeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2CC3E0A24F4608350657D80 /* LowPowerModeMonitor.swift */; }; 01EA87DB89817609EC02BD93 /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AA39C97E8A959E9A5B6F04 /* FocusCapabilityFlickerGate.swift */; }; 0203A1A205162551A22FAC98 /* PermissionGuidanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C41A249D9538B91B7FA8A9 /* PermissionGuidanceController.swift */; }; 023A1A2A26B192E244192D8D /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E824A5614BC7E7FCA05AD29 /* TrailingDuplicationFilterTests.swift */; }; @@ -584,6 +585,7 @@ C61C4FCC58C9198C407B437F /* EmojiSynonymCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FBE9101AF744A4D205B32D /* EmojiSynonymCatalog.swift */; }; C63F95C324C29940FAC6B973 /* de-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4B8665A5495891F9E3DDA48B /* de-100k.txt */; }; C657A9C35E432D0A21D96F9F /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1499B80A697DAF5ABD8298E /* GhostTextPreview.swift */; }; + C6867B979A03C7928BC83966 /* LowPowerModeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2CC3E0A24F4608350657D80 /* LowPowerModeMonitor.swift */; }; C68E65AF917EB511AC604C80 /* PostExhaustionAcceptanceStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4A5E6B82A38B9A579E5AA2 /* PostExhaustionAcceptanceStateTests.swift */; }; C6EAB2503D7186C0B2CF417D /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5B7FAD5A5E141AD9926D4A8 /* SystemResourceSampler.swift */; }; C6EE8C731928DB2A26945127 /* FocusTrackingModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 341A1BBA636AEBA2491F6A26 /* FocusTrackingModelTests.swift */; }; @@ -1207,6 +1209,7 @@ F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuard.swift; sourceTree = ""; }; F1E69CFA319C5F9E4C30F699 /* SettingsQuickLinkCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsQuickLinkCard.swift; sourceTree = ""; }; F26F50467C83B202FB4BAC2A /* WelcomePersonalizeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePersonalizeStepView.swift; sourceTree = ""; }; + F2CC3E0A24F4608350657D80 /* LowPowerModeMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowPowerModeMonitor.swift; sourceTree = ""; }; F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEdgeIconIndicatorView.swift; sourceTree = ""; }; F3D991D3536ED989C89AF44D /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; F47CD9DF50E7192A70D1870E /* CotabbyDebugOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptions.swift; sourceTree = ""; }; @@ -1438,6 +1441,7 @@ 1D6F00DFDE0013A930670603 /* Power */ = { isa = PBXGroup; children = ( + F2CC3E0A24F4608350657D80 /* LowPowerModeMonitor.swift */, DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */, ); path = Power; @@ -3286,6 +3290,7 @@ 163735A1B906AE8B009CC547 /* LlamaRuntimeManager.swift in Sources */, F50F54FB1B3B2B2E464B8683 /* LlamaRuntimeModels.swift in Sources */, 3A5BA316620E502DAED81EE1 /* LlamaSuggestionEngine.swift in Sources */, + C6867B979A03C7928BC83966 /* LowPowerModeMonitor.swift in Sources */, 647F30C40CE57EDFDA95958F /* MacroController.swift in Sources */, 1D54C941ED086C6427CFD773 /* MacroEngine.swift in Sources */, A614AD79BE724ABA3721613B /* MacroModels.swift in Sources */, @@ -3556,6 +3561,7 @@ 7BEA76E69707BC760B0D2394 /* LlamaRuntimeManager.swift in Sources */, D42BBBD704B21898047615E0 /* LlamaRuntimeModels.swift in Sources */, 3CEF8F66CBBD5D828135FF9E /* LlamaSuggestionEngine.swift in Sources */, + 01D6FF389B0E14C5BFDE4FBB /* LowPowerModeMonitor.swift in Sources */, E04950B45EF6C6CE47EE5081 /* MacroController.swift in Sources */, 2EDFE6F33018D2D43D5CB813 /* MacroEngine.swift in Sources */, BBBD7B4628BA1160DD6B4BDB /* MacroModels.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index f0bc4262..762b408b 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -23,6 +23,29 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, + disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + disabledDomains: PerDomainDisableSettings.disabledDomains(), + suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + inputMonitoringGranted: permissionManager.inputMonitoringGranted, + focusSnapshot: focusModel.snapshot + ) { + handleSupportedSnapshot(focusModel.snapshot) + } + } + + /// Reconciles coordinator state whenever macOS Low Power Mode toggles, so autocomplete pauses or + /// resumes live without waiting for the next keystroke or focus change to notice. + func handleLowPowerModeChange() { + CotabbyLogger.suggestion.debug("Low Power Mode state changed, reconciling") + reconcileWithCurrentEnvironment() + + if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -60,6 +83,8 @@ extension SuggestionCoordinator { SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -90,6 +115,8 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift index 8bd4dfa4..6b6a59a2 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Lifecycle.swift @@ -63,6 +63,8 @@ extension SuggestionCoordinator { SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -77,6 +79,8 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index bd436b0c..eca44d03 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -971,6 +971,8 @@ extension SuggestionCoordinator { SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, temporarilyPaused: settingsSnapshot.isTemporarilyPaused, + isLowPowerModeActive: lowPowerModeProvider.isLowPowerModeEnabled, + isLowPowerModeAutoDisableEnabled: settingsSnapshot.isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index b207e042..a7ce6f6f 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -24,6 +24,9 @@ final class SuggestionCoordinator: ObservableObject { // Core collaborators. The coordinator depends on capability-shaped protocols here so its // orchestration logic stays separated from concrete service implementations. let permissionManager: any SuggestionPermissionProviding + /// Live macOS Low Power Mode state. Read (alongside the matching settings toggle) by the + /// availability gate so predictions and visual context both pause automatically to save battery. + let lowPowerModeProvider: any SuggestionLowPowerModeProviding let focusModel: any SuggestionFocusProviding let inputMonitor: any SuggestionInputMonitoring let overlayController: any SuggestionOverlayControlling @@ -139,6 +142,7 @@ final class SuggestionCoordinator: ObservableObject { init( permissionManager: any SuggestionPermissionProviding, + lowPowerModeProvider: any SuggestionLowPowerModeProviding, focusModel: any SuggestionFocusProviding, inputMonitor: any SuggestionInputMonitoring, overlayController: any SuggestionOverlayControlling, @@ -161,6 +165,7 @@ final class SuggestionCoordinator: ObservableObject { forKey: Self.totalTabAcceptedWordCountDefaultsKey) self.permissionManager = permissionManager + self.lowPowerModeProvider = lowPowerModeProvider self.focusModel = focusModel self.inputMonitor = inputMonitor self.overlayController = overlayController @@ -207,6 +212,13 @@ final class SuggestionCoordinator: ObservableObject { } .store(in: &cancellables) + lowPowerModeProvider.isLowPowerModeEnabledPublisher + .dropFirst() + .sink { [weak self] _ in + self?.handleLowPowerModeChange() + } + .store(in: &cancellables) + // The monitor and overlay controller are callback-driven. The coordinator translates those // callbacks back into its state-machine methods. inputMonitor.onEvent = { [weak self] event in diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 33a4df83..94dc38ef 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -24,6 +24,10 @@ final class CotabbyAppEnvironment { let openAICompatibleConnectionModel: OpenAICompatibleConnectionModel let foundationModelAvailabilityService: FoundationModelAvailabilityService let powerSourceMonitor: PowerSourceMonitor + /// Owned here (not just inside `SuggestionCoordinator`) for the same reason as + /// `powerSourceMonitor`: a single long-lived instance shared for the app's lifetime rather than + /// re-created per observer. + let lowPowerModeMonitor: LowPowerModeMonitor /// Detects when a composing input method (Japanese kana, Chinese pinyin, Korean hangul, ...) is /// active so `SuggestionInserter` commits accepted text through an IME-safe path instead of a /// synthetic keystroke the input method would swallow. See `KeyboardInputSourceMonitor`. @@ -65,6 +69,7 @@ final class CotabbyAppEnvironment { ) let foundationModelAvailabilityService = FoundationModelAvailabilityService() let powerSourceMonitor = PowerSourceMonitor() + let lowPowerModeMonitor = LowPowerModeMonitor() let keyboardInputSourceMonitor = KeyboardInputSourceMonitor() let suppressionController = InputSuppressionController() let inputMonitor = InputMonitor( @@ -245,6 +250,7 @@ final class CotabbyAppEnvironment { ) let suggestionCoordinator = SuggestionCoordinator( permissionManager: permissionManager, + lowPowerModeProvider: lowPowerModeMonitor, focusModel: focusModel, inputMonitor: inputMonitor, overlayController: overlayController, @@ -314,6 +320,7 @@ final class CotabbyAppEnvironment { self.openAICompatibleConnectionModel = openAICompatibleConnectionModel self.foundationModelAvailabilityService = foundationModelAvailabilityService self.powerSourceMonitor = powerSourceMonitor + self.lowPowerModeMonitor = lowPowerModeMonitor self.keyboardInputSourceMonitor = keyboardInputSourceMonitor self.clipboardContextProvider = clipboardContextProvider self.suggestionCoordinator = suggestionCoordinator diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index 2f8596d1..630778fc 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -11,6 +11,10 @@ struct SuggestionGeneralSettings: Equatable { var disabledAppRules: [DisabledApplicationRule] var suggestInIntegratedTerminals: Bool var isPerformanceTrackingEnabled: Bool + /// When true (the default), Cotabby automatically pauses suggestions while macOS Low Power Mode + /// is on, so generation does not fight the OS's own battery-saving throttling. Read alongside the + /// live `LowPowerModeMonitor` state by `SuggestionAvailabilityEvaluator`. + var isLowPowerModeAutoDisableEnabled: Bool } /// Backend selection, endpoint configuration, and power-source routing. @@ -141,6 +145,11 @@ extension SuggestionSettingsData { set { general.isPerformanceTrackingEnabled = newValue } } + var isLowPowerModeAutoDisableEnabled: Bool { + get { general.isLowPowerModeAutoDisableEnabled } + set { general.isLowPowerModeAutoDisableEnabled = newValue } + } + var selectedEngine: SuggestionEngineKind { get { engine.selectedEngine } set { engine.selectedEngine = newValue } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 1624cd35..3281f2bf 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -86,6 +86,10 @@ final class SuggestionSettingsModel: ObservableObject { /// default user never pays any extra storage or write cost — recording only kicks in once the /// user opts in from Settings. @Published private(set) var isPerformanceTrackingEnabled: Bool + /// Whether Cotabby automatically pauses suggestions while macOS Low Power Mode is on. Defaults to + /// true so a fresh install saves battery out of the box; read live alongside `LowPowerModeMonitor` + /// by `SuggestionAvailabilityEvaluator`. + @Published private(set) var isLowPowerModeAutoDisableEnabled: Bool /// Whether Cotabby's status item is inserted into the menu bar. The process and suggestion /// pipeline remain active when hidden; launching the app again opens Settings as the recovery path. @Published private(set) var isMenuBarIconVisible: Bool @@ -220,6 +224,7 @@ final class SuggestionSettingsModel: ObservableObject { enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes automaticallyFixTypos = data.automaticallyFixTypos isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled + isLowPowerModeAutoDisableEnabled = data.isLowPowerModeAutoDisableEnabled isMenuBarIconVisible = data.isMenuBarIconVisible isMenuBarWordCountVisible = data.isMenuBarWordCountVisible mirrorPreference = data.mirrorPreference @@ -295,6 +300,7 @@ final class SuggestionSettingsModel: ObservableObject { enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes automaticallyFixTypos = data.automaticallyFixTypos isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled + isLowPowerModeAutoDisableEnabled = data.isLowPowerModeAutoDisableEnabled isMenuBarIconVisible = data.isMenuBarIconVisible isMenuBarWordCountVisible = data.isMenuBarWordCountVisible mirrorPreference = data.mirrorPreference @@ -352,7 +358,8 @@ final class SuggestionSettingsModel: ObservableObject { pauseState: pauseState, disabledAppRules: disabledAppRules, suggestInIntegratedTerminals: suggestInIntegratedTerminals, - isPerformanceTrackingEnabled: isPerformanceTrackingEnabled + isPerformanceTrackingEnabled: isPerformanceTrackingEnabled, + isLowPowerModeAutoDisableEnabled: isLowPowerModeAutoDisableEnabled ), engine: SuggestionEngineSettings( selectedEngine: selectedEngine, @@ -440,6 +447,7 @@ final class SuggestionSettingsModel: ObservableObject { isTemporarilyPaused: settings.general.pauseState?.isActive() == true, disabledAppBundleIdentifiers: Set(settings.general.disabledAppRules.map(\.bundleIdentifier)), suggestInIntegratedTerminals: settings.general.suggestInIntegratedTerminals, + isLowPowerModeAutoDisableEnabled: settings.general.isLowPowerModeAutoDisableEnabled, selectedEngine: settings.engine.selectedEngine, selectedWordCountPreset: settings.completion.selectedWordCountPreset, isUsingCustomWordCountRange: settings.completion.isUsingCustomWordCountRange, @@ -755,6 +763,15 @@ final class SuggestionSettingsModel: ObservableObject { store.savePerformanceTrackingEnabled(enabled) } + func setLowPowerModeAutoDisableEnabled(_ enabled: Bool) { + guard isLowPowerModeAutoDisableEnabled != enabled else { + return + } + + isLowPowerModeAutoDisableEnabled = enabled + store.saveLowPowerModeAutoDisableEnabled(enabled) + } + func setMenuBarIconVisible(_ visible: Bool) { guard isMenuBarIconVisible != visible else { return @@ -1361,13 +1378,18 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $customWordCountLowWords, $customWordCountHighWords ) - // `extendedContext` shares its outer slot with `suggestInIntegratedTerminals` and - // `isSurfaceContextEnabled` via one grouped `CombineLatest3` so new toggles cost no extra - // top-level slot (the outer is at the cap). + // `extendedContext` shares its outer slot with `suggestInIntegratedTerminals`, + // `isSurfaceContextEnabled`, and `isLowPowerModeAutoDisableEnabled` via one grouped + // `CombineLatest4` so new toggles cost no extra top-level slot (the outer is at the cap). return Publishers.CombineLatest4( primary, $acceptanceGranularity, - Publishers.CombineLatest3($extendedContext, $suggestInIntegratedTerminals, $isSurfaceContextEnabled), + Publishers.CombineLatest4( + $extendedContext, + $suggestInIntegratedTerminals, + $isSurfaceContextEnabled, + $isLowPowerModeAutoDisableEnabled + ), customRange ) .map { primaryTuple, granularity, extendedContextTuple, customRangeTuple in @@ -1380,12 +1402,14 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (debounce, focusPoll, multiLine, acceptToggles) = timing let (autoAcceptPunctuation, addSpaceAfterAccept, streamWhileGenerating) = acceptToggles let (isCustomActive, customLow, customHigh) = customRangeTuple - let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled) = extendedContextTuple + let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled, lowPowerModeAutoDisableEnabled) = + extendedContextTuple return SuggestionSettingsSnapshot( isGloballyEnabled: globallyEnabled, isTemporarilyPaused: pauseState?.isActive() == true, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), suggestInIntegratedTerminals: suggestInIntegratedTerminals, + isLowPowerModeAutoDisableEnabled: lowPowerModeAutoDisableEnabled, selectedEngine: engine, selectedWordCountPreset: wordCountPreset, isUsingCustomWordCountRange: isCustomActive, diff --git a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift index 51a95e1b..96d9b182 100644 --- a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift +++ b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift @@ -110,6 +110,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// xterm.js surfaces). Power users can opt back in. Travels in the snapshot so the availability /// gate sees the live value alongside the other "where Cotabby runs" rules. let suggestInIntegratedTerminals: Bool + /// When true (the default), the availability gate suppresses suggestions while macOS Low Power + /// Mode is on (see `LowPowerModeMonitor`). Travels in the snapshot so the gate reads the live + /// setting value alongside the other "where Cotabby runs" rules. + let isLowPowerModeAutoDisableEnabled: Bool let selectedEngine: SuggestionEngineKind let selectedWordCountPreset: SuggestionWordCountPreset /// When true, the generation pipeline uses `customWordCountRange` for the length budget and diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index 5ae1021c..c34c3f79 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -22,6 +22,15 @@ protocol SuggestionPermissionProviding: AnyObject { var screenRecordingGrantedPublisher: AnyPublisher { get } } +/// Live OS-level Low Power Mode state, mirroring `SuggestionPermissionProviding`'s shape: a +/// synchronous read plus an erased publisher so the coordinator can subscribe without depending on +/// `LowPowerModeMonitor`'s concrete `@Published` storage. +@MainActor +protocol SuggestionLowPowerModeProviding: AnyObject { + var isLowPowerModeEnabled: Bool { get } + var isLowPowerModeEnabledPublisher: AnyPublisher { get } +} + @MainActor protocol SuggestionFocusProviding: AnyObject { var snapshot: FocusSnapshot { get } diff --git a/Cotabby/Services/Power/LowPowerModeMonitor.swift b/Cotabby/Services/Power/LowPowerModeMonitor.swift new file mode 100644 index 00000000..9b4ed5d3 --- /dev/null +++ b/Cotabby/Services/Power/LowPowerModeMonitor.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation + +/// Tracks whether macOS Low Power Mode is currently enabled and publishes changes so power-aware +/// features (such as automatically pausing autocomplete to save battery) can react live. +/// +/// Lives on the main actor because `@Published` feeds SwiftUI and `SuggestionCoordinator`'s gating +/// logic, and `NSProcessInfoPowerStateDidChange` is delivered on an unspecified thread — the +/// subscription below hops back to main before touching `@Published` state. Distinct from +/// `PowerSourceMonitor` (which tracks AC vs. battery via IOKit): Low Power Mode is a separate OS-level +/// toggle the user (or the system, automatically below 20% battery) can flip independently of whether +/// the Mac is plugged in. +@MainActor +final class LowPowerModeMonitor: ObservableObject { + @Published private(set) var isLowPowerModeEnabled: Bool + + private var powerStateObserver: NSObjectProtocol? + + init() { + isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + + // `NSProcessInfoPowerStateDidChange` does not guarantee a delivery queue, unlike + // `NSWorkspace` notifications elsewhere in this folder, so this explicitly targets the main + // queue rather than assuming the callback is already there. + powerStateObserver = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.refreshLowPowerModeState() + } + } + } + + deinit { + if let powerStateObserver { + NotificationCenter.default.removeObserver(powerStateObserver) + } + } + + func refreshLowPowerModeState() { + isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } +} + +extension LowPowerModeMonitor: SuggestionLowPowerModeProviding { + /// The coordinator subscribes through this erased publisher so it can depend on + /// `SuggestionLowPowerModeProviding` instead of this type's concrete `@Published` storage. + var isLowPowerModeEnabledPublisher: AnyPublisher { + $isLowPowerModeEnabled.eraseToAnyPublisher() + } +} diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index fd7f926b..70d0861a 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -113,6 +113,7 @@ struct SuggestionSettingsStore { private static let spellingDictionaryCodesDefaultsKey = "cotabbyEnabledSpellingDictionaryCodes" private static let automaticallyFixTyposDefaultsKey = "cotabbyAutomaticallyFixTypos" private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled" + private static let lowPowerModeAutoDisableDefaultsKey = "cotabbyLowPowerModeAutoDisableEnabled" /// Shared with `CotabbyApp` because SwiftUI's scene-level `@AppStorage` is what invalidates the /// `MenuBarExtra` insertion binding when the settings model writes this preference. static let menuBarIconVisibleDefaultsKey = "cotabbyMenuBarIconVisible" @@ -190,6 +191,7 @@ struct SuggestionSettingsStore { spellingDictionaryCodesDefaultsKey, automaticallyFixTyposDefaultsKey, performanceTrackingEnabledDefaultsKey, + lowPowerModeAutoDisableDefaultsKey, menuBarWordCountVisibleDefaultsKey, mirrorPreferenceDefaultsKey, userNameDefaultsKey, @@ -335,6 +337,11 @@ struct SuggestionSettingsStore { // in from the Performance pane. let resolvedPerformanceTrackingEnabled = userDefaults.object(forKey: Self.performanceTrackingEnabledDefaultsKey) as? Bool ?? false + // Defaults to true: a fresh install pauses autocomplete under Low Power Mode out of the box + // to avoid fighting the OS's own battery-saving throttling. Existing installs (no key) get + // the same, matching the issue's ask for this to be automatic with an opt-out. + let resolvedLowPowerModeAutoDisableEnabled = + userDefaults.object(forKey: Self.lowPowerModeAutoDisableDefaultsKey) as? Bool ?? true // Existing installs keep the status item unless the user explicitly hides it. Hiding the // item must never terminate the accessory app because autocomplete continues in the background. let resolvedMenuBarIconVisible = @@ -504,7 +511,8 @@ struct SuggestionSettingsStore { pauseState: resolvedPauseState, disabledAppRules: resolvedDisabledAppRules, suggestInIntegratedTerminals: resolvedSuggestInIntegratedTerminals, - isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled + isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled, + isLowPowerModeAutoDisableEnabled: resolvedLowPowerModeAutoDisableEnabled ), engine: SuggestionEngineSettings( selectedEngine: resolvedEngine, @@ -610,6 +618,7 @@ struct SuggestionSettingsStore { saveEnabledSpellingDictionaryCodes(data.enabledSpellingDictionaryCodes) saveAutomaticallyFixTypos(data.automaticallyFixTypos) savePerformanceTrackingEnabled(data.isPerformanceTrackingEnabled) + saveLowPowerModeAutoDisableEnabled(data.isLowPowerModeAutoDisableEnabled) saveMenuBarIconVisible(data.isMenuBarIconVisible) saveMenuBarWordCountVisible(data.isMenuBarWordCountVisible) saveMirrorPreference(data.mirrorPreference) @@ -826,6 +835,10 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.performanceTrackingEnabledDefaultsKey) } + func saveLowPowerModeAutoDisableEnabled(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.lowPowerModeAutoDisableDefaultsKey) + } + func saveMenuBarIconVisible(_ visible: Bool) { userDefaults.set(visible, forKey: Self.menuBarIconVisibleDefaultsKey) } diff --git a/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift b/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift index e7c409b1..34b447f0 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift @@ -10,6 +10,8 @@ enum SuggestionAvailabilityEvaluator { static func disabledReason( globallyEnabled: Bool = true, temporarilyPaused: Bool = false, + isLowPowerModeActive: Bool = false, + isLowPowerModeAutoDisableEnabled: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -25,31 +27,23 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is temporarily paused." } - if let bundleIdentifier = focusSnapshot.bundleIdentifier, - disabledAppBundleIdentifiers.contains(bundleIdentifier) { - return "Cotabby is disabled in \(focusSnapshot.applicationName)." - } - - // Per-site disable: when focus capture resolved a page URL, a host on the user's disabled list - // (exact or parent domain) suppresses autocomplete the same way a disabled app does. The URL is - // nil unless the feature is enabled and a browser exposed it, and the list is empty by default, - // so non-browser focus is unaffected. - if let urlString = focusSnapshot.context?.focusedURLString, - let host = BrowserDomain.host(fromURLString: urlString), - BrowserDomain.isHostDisabled(host, disabledDomains: disabledDomains) { - return "Cotabby is disabled on \(host)." + // Same tier as the two checks above: a full suppression the user opted into, rather than a + // per-app/per-domain/per-field rule. Gated behind its own toggle (default on) so a user who + // wants autocomplete regardless of battery impact can opt back out. + guard !isSuppressedByLowPowerMode( + isActive: isLowPowerModeActive, + autoDisableEnabled: isLowPowerModeAutoDisableEnabled + ) else { + return "Cotabby is paused because Low Power Mode is on." } - if TerminalAppDetector.isTerminal(bundleIdentifier: focusSnapshot.bundleIdentifier) { - return "Cotabby is not available in terminal apps." - } - - // Integrated terminals (VS Code / Cursor xterm.js) share their app's bundle id with the - // editor and chat, so they slip past the blocklist above. Suppress them here unless the user - // has opted back in, keeping ghost text out of shell prompts and command output while the - // editor and Copilot chat in the same window keep suggesting. - if !suggestInIntegratedTerminals, focusSnapshot.context?.isIntegratedTerminal == true { - return "Cotabby is not available in the integrated terminal." + if let reason = locationDisabledReason( + disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, + disabledDomains: disabledDomains, + suggestInIntegratedTerminals: suggestInIntegratedTerminals, + focusSnapshot: focusSnapshot + ) { + return reason } guard inputMonitoringGranted else { @@ -71,6 +65,8 @@ enum SuggestionAvailabilityEvaluator { static func shouldSchedulePrediction( globallyEnabled: Bool = true, temporarilyPaused: Bool = false, + isLowPowerModeActive: Bool = false, + isLowPowerModeAutoDisableEnabled: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -80,6 +76,8 @@ enum SuggestionAvailabilityEvaluator { disabledReason( globallyEnabled: globallyEnabled, temporarilyPaused: temporarilyPaused, + isLowPowerModeActive: isLowPowerModeActive, + isLowPowerModeAutoDisableEnabled: isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, @@ -103,6 +101,8 @@ enum SuggestionAvailabilityEvaluator { static func shouldCaptureVisualContext( globallyEnabled: Bool = true, temporarilyPaused: Bool = false, + isLowPowerModeActive: Bool = false, + isLowPowerModeAutoDisableEnabled: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -122,6 +122,8 @@ enum SuggestionAvailabilityEvaluator { return disabledReason( globallyEnabled: globallyEnabled, temporarilyPaused: temporarilyPaused, + isLowPowerModeActive: isLowPowerModeActive, + isLowPowerModeAutoDisableEnabled: isLowPowerModeAutoDisableEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, @@ -144,4 +146,50 @@ enum SuggestionAvailabilityEvaluator { return SuggestionRequestFactory.shouldGenerateSuggestion(for: context.precedingText) } + + /// Whether the Low Power Mode gate should suppress autocomplete: the Mac must actually be in Low + /// Power Mode, and the user must not have opted out of the auto-pause behavior. Broken out of + /// `disabledReason` to keep that function's branch count readable as gates accumulate. + private static func isSuppressedByLowPowerMode(isActive: Bool, autoDisableEnabled: Bool) -> Bool { + isActive && autoDisableEnabled + } + + /// The "where Cotabby runs" rules: per-app disable, per-site disable, standalone terminal apps, + /// and integrated terminals. Broken out of `disabledReason` (same behavior, same order) purely to + /// keep that function's branch count readable as gates accumulate. + private static func locationDisabledReason( + disabledAppBundleIdentifiers: Set, + disabledDomains: Set, + suggestInIntegratedTerminals: Bool, + focusSnapshot: FocusSnapshot + ) -> String? { + if let bundleIdentifier = focusSnapshot.bundleIdentifier, + disabledAppBundleIdentifiers.contains(bundleIdentifier) { + return "Cotabby is disabled in \(focusSnapshot.applicationName)." + } + + // Per-site disable: when focus capture resolved a page URL, a host on the user's disabled list + // (exact or parent domain) suppresses autocomplete the same way a disabled app does. The URL is + // nil unless the feature is enabled and a browser exposed it, and the list is empty by default, + // so non-browser focus is unaffected. + if let urlString = focusSnapshot.context?.focusedURLString, + let host = BrowserDomain.host(fromURLString: urlString), + BrowserDomain.isHostDisabled(host, disabledDomains: disabledDomains) { + return "Cotabby is disabled on \(host)." + } + + if TerminalAppDetector.isTerminal(bundleIdentifier: focusSnapshot.bundleIdentifier) { + return "Cotabby is not available in terminal apps." + } + + // Integrated terminals (VS Code / Cursor xterm.js) share their app's bundle id with the + // editor and chat, so they slip past the blocklist above. Suppress them here unless the user + // has opted back in, keeping ghost text out of shell prompts and command output while the + // editor and Copilot chat in the same window keep suggesting. + if !suggestInIntegratedTerminals, focusSnapshot.context?.isIntegratedTerminal == true { + return "Cotabby is not available in the integrated terminal." + } + + return nil + } } diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift index 6b3eceac..07ee5fc0 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift @@ -12,6 +12,21 @@ extension EngineAndModelPaneView { @ViewBuilder var powerSection: some View { Section("Power") { + Toggle( + isOn: Binding( + get: { suggestionSettings.isLowPowerModeAutoDisableEnabled }, + set: { suggestionSettings.setLowPowerModeAutoDisableEnabled($0) } + ) + ) { + SettingsRowLabel( + title: "Pause in Low Power Mode", + description: "Automatically pause suggestions while your Mac is in Low Power " + + "Mode, so Cotabby doesn't work against its battery-saving throttling.", + systemImage: "bolt.slash.circle" + ) + } + .settingsItem(.lowPowerModeAutoDisable) + Toggle( isOn: Binding( get: { suggestionSettings.isPowerBasedModelSwitchingEnabled }, diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 81413ca7..bc7bb534 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -57,6 +57,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case appleIntelligenceAvailability case modelStatus case selectedModel + case lowPowerModeAutoDisable case powerBasedModelSwitching case batteryModel case pluggedInModel @@ -137,6 +138,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .appleIntelligenceAvailability: return "Apple Intelligence Availability" case .modelStatus: return "Model Status" case .selectedModel: return "Selected Model" + case .lowPowerModeAutoDisable: return "Pause in Low Power Mode" case .powerBasedModelSwitching: return "Switch Based on Power Source" case .batteryModel: return "On Battery" case .pluggedInModel: return "Plugged In" @@ -212,6 +214,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .appleIntelligenceAvailability: return "apple.logo" case .modelStatus: return "info.circle" case .selectedModel: return "shippingbox" + case .lowPowerModeAutoDisable: return "bolt.slash.circle" case .powerBasedModelSwitching: return "battery.100.bolt" case .batteryModel: return "battery.25" case .pluggedInModel: return "powerplug" @@ -263,7 +266,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .extendedContext, .contextLivePreview: return .context case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, - .powerBasedModelSwitching, .batteryModel, .pluggedInModel, + .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: return .engineAndModel @@ -324,6 +327,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." case .modelStatus: return "Whether the local model is loaded and ready." case .selectedModel: return "Which downloaded model generates suggestions." + case .lowPowerModeAutoDisable: return "Pause suggestions automatically to save battery." case .powerBasedModelSwitching: return "Use a different engine or model by power source." case .batteryModel: return "Engine and model used while on battery." case .pluggedInModel: return "Engine and model used while plugged in." @@ -485,6 +489,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .selectedModel: return ["model", "gguf", "pick", "selected", "active model", "choose model", "current model", "default model"] + case .lowPowerModeAutoDisable: + return ["low power mode", "power saving", "battery saver", "pause", "disable", + "throttle", "conserve battery", "power", "automatic", "auto disable"] case .powerBasedModelSwitching: return ["power", "battery", "plugged", "energy", "ac", "charger", "switch model", "power source", "auto switch", "adaptive", diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift index ded8765e..ca09eb6e 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift @@ -493,6 +493,7 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { settingsProvider.snapshot = settingsSnapshot let coordinator = SuggestionCoordinator( permissionManager: StubSuggestionPermissionProvider(), + lowPowerModeProvider: StubSuggestionLowPowerModeProvider(), focusModel: StubSuggestionFocusProvider(snapshot: focusSnapshot), inputMonitor: inputMonitor, overlayController: StubSuggestionOverlayController(state: overlayState), @@ -534,6 +535,17 @@ private final class StubSuggestionPermissionProvider: SuggestionPermissionProvid } } +@MainActor +private final class StubSuggestionLowPowerModeProvider: SuggestionLowPowerModeProviding { + var isLowPowerModeEnabled = false + + private let subject = PassthroughSubject() + + var isLowPowerModeEnabledPublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } +} + @MainActor private final class StubSuggestionFocusProvider: SuggestionFocusProviding { var snapshot: FocusSnapshot diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift index 0700c5cc..2a31507d 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift @@ -307,6 +307,32 @@ final class SuggestionCoordinatorInputTests: XCTestCase { XCTAssertEqual(rig.visualContext.cancelCalls, [true]) } + // MARK: - Low Power Mode changes + + func test_lowPowerModeChange_disablesPipelineWhenActiveAndAutoDisableEnabled() { + // The fixture default (`isLowPowerModeAutoDisableEnabled: true`) mirrors production. + let rig = retained(makeCoordinatorRig()) + rig.lowPowerModeProvider.isLowPowerModeEnabled = true + + rig.coordinator.handleLowPowerModeChange() + + guard case let .disabled(reason) = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + XCTAssertEqual(reason, "Cotabby is paused because Low Power Mode is on.") + } + + func test_lowPowerModeChange_leavesPipelineRunningWhenAutoDisableOptedOut() { + let rig = retained(makeCoordinatorRig( + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(isLowPowerModeAutoDisableEnabled: false) + )) + rig.lowPowerModeProvider.isLowPowerModeEnabled = true + + rig.coordinator.handleLowPowerModeChange() + + XCTAssertEqual(rig.coordinator.state, .idle, "Opting out must keep autocomplete running in Low Power Mode") + } + func test_suppressedSyntheticInput_logsWithoutMutatingState() { let rig = retained(makeCoordinatorRig()) let stateBefore = rig.coordinator.state diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift index f2784a7c..68a31bfe 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift @@ -117,4 +117,23 @@ final class SuggestionCoordinatorLifecycleTests: XCTestCase { XCTAssertTrue(rig.visualContext.startedSessions.isEmpty) XCTAssertEqual(rig.visualContext.cancelCalls, [true]) } + + /// Mirrors `test_settingsChange_pausingDoesNotRestartThePipeline`: a settings change that lands + /// while the Mac is already in Low Power Mode must not restart generation, exactly like the + /// global-off and paused cases it shares a gating tier with. + func test_settingsChange_autoDisablingWhileAlreadyInLowPowerModeDoesNotRestartThePipeline() { + let rig = retained(makeCoordinatorRig()) + rig.lowPowerModeProvider.isLowPowerModeEnabled = true + + rig.coordinator.handleSuggestionSettingsChange( + CotabbyTestFixtures.settingsSnapshot( + isLowPowerModeAutoDisableEnabled: true, + debounceMilliseconds: 1 + ) + ) + + XCTAssertNotEqual(rig.coordinator.state, .debouncing) + XCTAssertTrue(rig.visualContext.startedSessions.isEmpty) + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } } diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift index 34a3a3e2..743035ce 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsDomainTests.swift @@ -28,6 +28,7 @@ final class SuggestionSettingsDomainTests: XCTestCase { defaults.set(SuggestionEngineKind.appleIntelligence.rawValue, forKey: "cotabbySelectedEngine") defaults.set(true, forKey: "cotabbyClipboardContextEnabled") defaults.set(false, forKey: "cotabbyShowAcceptanceHint") + defaults.set(false, forKey: "cotabbyLowPowerModeAutoDisableEnabled") let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) @@ -35,6 +36,7 @@ final class SuggestionSettingsDomainTests: XCTestCase { XCTAssertEqual(data.engine.selectedEngine, .appleIntelligence) XCTAssertTrue(data.context.isClipboardContextEnabled) XCTAssertFalse(data.presentation.showAcceptanceHint) + XCTAssertFalse(data.general.isLowPowerModeAutoDisableEnabled) XCTAssertEqual(data.shortcuts.acceptance.keyCode, SuggestionSettingsStore.defaultAcceptanceKeyCode) } diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift index 1ac8a26c..e3cf2b6d 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift @@ -55,6 +55,7 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setOfferTypoCorrections(true) model.setAutomaticallyFixTypos(true) model.setPerformanceTrackingEnabled(true) + model.setLowPowerModeAutoDisableEnabled(false) model.setMenuBarIconVisible(false) model.setMenuBarWordCountVisible(false) model.setMirrorPreference(.alwaysMirror) @@ -101,6 +102,7 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertTrue(reloaded.offerTypoCorrections) XCTAssertTrue(reloaded.automaticallyFixTypos) XCTAssertTrue(reloaded.isPerformanceTrackingEnabled) + XCTAssertFalse(reloaded.isLowPowerModeAutoDisableEnabled) XCTAssertFalse(reloaded.isMenuBarIconVisible) XCTAssertFalse(reloaded.isMenuBarWordCountVisible) XCTAssertEqual(reloaded.mirrorPreference, .alwaysMirror) @@ -167,6 +169,7 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setOfferTypoCorrections(false) model.setAutomaticallyFixTypos(true) model.setPerformanceTrackingEnabled(true) + model.setLowPowerModeAutoDisableEnabled(false) model.setMenuBarWordCountVisible(false) model.setMirrorPreference(.alwaysMirror) model.setMultiLineEnabled(true) @@ -220,6 +223,7 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(model.offerTypoCorrections, pristine.offerTypoCorrections) XCTAssertEqual(model.automaticallyFixTypos, pristine.automaticallyFixTypos) XCTAssertEqual(model.isPerformanceTrackingEnabled, pristine.isPerformanceTrackingEnabled) + XCTAssertEqual(model.isLowPowerModeAutoDisableEnabled, pristine.isLowPowerModeAutoDisableEnabled) XCTAssertEqual(model.isMenuBarWordCountVisible, pristine.isMenuBarWordCountVisible) XCTAssertEqual(model.mirrorPreference, pristine.mirrorPreference) XCTAssertEqual(model.isMultiLineEnabled, pristine.isMultiLineEnabled) @@ -626,6 +630,13 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setCustomWordCountRange(low: 2, high: 200) XCTAssertEqual(snapshots.last?.customWordCountRange, SuggestionWordRange(lowWords: 2, highWords: 50)) XCTAssertEqual(snapshots.last?.isUsingCustomWordCountRange, true) + + // Regression guard for the grouped publisher this setting shares with `extendedContext` and + // `suggestInIntegratedTerminals`: a miswired tuple destructure would silently carry a stale + // or swapped value instead of failing to compile. + XCTAssertEqual(snapshots.last?.isLowPowerModeAutoDisableEnabled, true) + model.setLowPowerModeAutoDisableEnabled(false) + XCTAssertEqual(snapshots.last?.isLowPowerModeAutoDisableEnabled, false) } func test_snapshot_reflectsDisabledBundlesAndExtendedContext() { diff --git a/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift b/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift index 9138b02d..331d5de3 100644 --- a/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift @@ -156,6 +156,16 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertTrue(data.isMenuBarIconVisible) } + /// A fresh install pauses autocomplete in Low Power Mode out of the box, matching the issue's + /// ask for this to be automatic with an opt-out rather than opt-in. + func test_load_lowPowerModeAutoDisableDefaultsOn() async { + let defaults = makeIsolatedDefaults() + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertTrue(data.isLowPowerModeAutoDisableEnabled) + } + func test_saveThenLoad_roundTripsScalarFields() async { let defaults = makeIsolatedDefaults() let store = SuggestionSettingsStore(userDefaults: defaults) @@ -170,6 +180,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveMenuBarWordCountVisible(false) store.saveFadeInSuggestions(false) store.saveFadeInDurationSeconds(0.25) + store.saveLowPowerModeAutoDisableEnabled(false) let data = store.load(configuration: .standard) @@ -183,6 +194,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertFalse(data.isMenuBarWordCountVisible) XCTAssertFalse(data.fadeInSuggestions) XCTAssertEqual(data.fadeInDurationSeconds, 0.25, accuracy: 0.0001) + XCTAssertFalse(data.isLowPowerModeAutoDisableEnabled) } func test_saveThenLoad_roundTripsIndefinitePause() async { @@ -617,6 +629,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveEnabledSpellingDictionaryCodes([]) store.saveAutomaticallyFixTypos(true) store.savePerformanceTrackingEnabled(true) + store.saveLowPowerModeAutoDisableEnabled(false) store.saveMenuBarWordCountVisible(false) store.saveMirrorPreference(.alwaysMirror) store.saveUserName("Ada") diff --git a/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift index fadf050c..5846411b 100644 --- a/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift @@ -144,6 +144,88 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertEqual(reason, "Cotabby is temporarily paused.") } + // MARK: - Low Power Mode gating + + func test_disabledReason_whenLowPowerModeActiveAndAutoDisableEnabled_returnsFixedCopy() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + isLowPowerModeActive: true, + isLowPowerModeAutoDisableEnabled: true, + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertEqual(reason, "Cotabby is paused because Low Power Mode is on.") + } + + /// The user opted out of auto-pausing: Low Power Mode being active must not suppress + /// suggestions when the toggle is off. + func test_disabledReason_whenLowPowerModeActiveButAutoDisableOptedOut_returnsNil() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + isLowPowerModeActive: true, + isLowPowerModeAutoDisableEnabled: false, + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertNil(reason, "Opting out should let suggestions run in Low Power Mode") + } + + /// The setting is on, but the Mac is not currently in Low Power Mode: nothing should suppress. + func test_disabledReason_whenAutoDisableEnabledButLowPowerModeInactive_returnsNil() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + isLowPowerModeActive: false, + isLowPowerModeAutoDisableEnabled: true, + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertNil(reason) + } + + /// Same tier as the global-off and temporarily-paused checks: Low Power Mode should win over a + /// per-app disable reason so the user sees the more fundamental cause. + func test_disabledReason_lowPowerMode_winsOverAppDisabled() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + isLowPowerModeActive: true, + isLowPowerModeAutoDisableEnabled: true, + disabledAppBundleIdentifiers: ["app.test"], + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertEqual(reason, "Cotabby is paused because Low Power Mode is on.") + } + + func test_shouldSchedulePrediction_falseWhenLowPowerModeActiveAndAutoDisableEnabled() { + let ok = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + isLowPowerModeActive: true, + isLowPowerModeAutoDisableEnabled: true, + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertFalse(ok) + } + + func test_shouldCaptureVisualContext_falseWhenLowPowerModeActiveAndAutoDisableEnabled() { + let ok = SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( + globallyEnabled: true, + isLowPowerModeActive: true, + isLowPowerModeAutoDisableEnabled: true, + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeSnapshot(capability: .supported), + isFastModeEnabled: false + ) + + XCTAssertFalse(ok) + } + func test_disabledReason_whenFocusedDomainIsDisabled_returnsSiteReason() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 429a5253..9d84d6e1 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -240,6 +240,7 @@ enum CotabbyTestFixtures { isTemporarilyPaused: Bool = false, disabledAppBundleIdentifiers: Set = [], suggestInIntegratedTerminals: Bool = false, + isLowPowerModeAutoDisableEnabled: Bool = true, selectedEngine: SuggestionEngineKind = .llamaOpenSource, selectedWordCountPreset: SuggestionWordCountPreset = .sevenToTwelve, isUsingCustomWordCountRange: Bool = false, @@ -269,6 +270,7 @@ enum CotabbyTestFixtures { isTemporarilyPaused: isTemporarilyPaused, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, suggestInIntegratedTerminals: suggestInIntegratedTerminals, + isLowPowerModeAutoDisableEnabled: isLowPowerModeAutoDisableEnabled, selectedEngine: selectedEngine, selectedWordCountPreset: selectedWordCountPreset, isUsingCustomWordCountRange: isUsingCustomWordCountRange, diff --git a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift index df96d813..b3ab5c5c 100644 --- a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift +++ b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift @@ -26,6 +26,17 @@ final class RigPermissionProvider: SuggestionPermissionProviding { } } +@MainActor +final class RigLowPowerModeProvider: SuggestionLowPowerModeProviding { + var isLowPowerModeEnabled = false + + let subject = PassthroughSubject() + + var isLowPowerModeEnabledPublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } +} + @MainActor final class RigFocusProvider: SuggestionFocusProviding { var snapshot: FocusSnapshot @@ -205,6 +216,7 @@ final class RigVisualContextCoordinator: VisualContextCoordinating { struct CoordinatorRig { let coordinator: SuggestionCoordinator let permissionProvider: RigPermissionProvider + let lowPowerModeProvider: RigLowPowerModeProvider let focusProvider: RigFocusProvider let inputMonitor: RigInputMonitor let overlayController: RigOverlayController @@ -231,6 +243,7 @@ func makeCoordinatorRig( context: snapshot ) let permissionProvider = RigPermissionProvider() + let lowPowerModeProvider = RigLowPowerModeProvider() let focusProvider = RigFocusProvider(snapshot: focusSnapshot) let inputMonitor = RigInputMonitor() let overlayController = RigOverlayController(state: overlayState) @@ -243,6 +256,7 @@ func makeCoordinatorRig( let interactionState = SuggestionInteractionState() let coordinator = SuggestionCoordinator( permissionManager: permissionProvider, + lowPowerModeProvider: lowPowerModeProvider, focusModel: focusProvider, inputMonitor: inputMonitor, overlayController: overlayController, @@ -265,6 +279,7 @@ func makeCoordinatorRig( return CoordinatorRig( coordinator: coordinator, permissionProvider: permissionProvider, + lowPowerModeProvider: lowPowerModeProvider, focusProvider: focusProvider, inputMonitor: inputMonitor, overlayController: overlayController, From c25d9c1b9c04a118a0cca54b0c174b1115e7d2a4 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:55:16 -0400 Subject: [PATCH 2/3] fix: handle low power mode transitions reliably --- .../Suggestion/SuggestionCoordinator.swift | 3 +- .../SuggestionSubsystemContracts.swift | 8 ++--- .../Services/Power/LowPowerModeMonitor.swift | 27 ++++++++++++----- .../Engine/EngineAndModelPaneView+Power.swift | 3 +- ...SuggestionCoordinatorAcceptanceTests.swift | 2 +- .../SuggestionCoordinatorInputTests.swift | 30 ++++++++++++++----- .../SuggestionCoordinatorLifecycleTests.swift | 9 ++++-- .../SuggestionCoordinatorTestSupport.swift | 24 ++++++++++++--- 8 files changed, 77 insertions(+), 29 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index a7ce6f6f..c8ff7a48 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -212,8 +212,7 @@ final class SuggestionCoordinator: ObservableObject { } .store(in: &cancellables) - lowPowerModeProvider.isLowPowerModeEnabledPublisher - .dropFirst() + lowPowerModeProvider.lowPowerModeChanges .sink { [weak self] _ in self?.handleLowPowerModeChange() } diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index c34c3f79..80250be2 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -22,13 +22,13 @@ protocol SuggestionPermissionProviding: AnyObject { var screenRecordingGrantedPublisher: AnyPublisher { get } } -/// Live OS-level Low Power Mode state, mirroring `SuggestionPermissionProviding`'s shape: a -/// synchronous read plus an erased publisher so the coordinator can subscribe without depending on -/// `LowPowerModeMonitor`'s concrete `@Published` storage. +/// Live OS-level Low Power Mode state: a synchronous read for the initial gate plus a changes-only +/// stream for later transitions. Keeping those roles explicit prevents a subscriber from having to +/// guess whether the publisher begins with a bootstrap value or its first real OS change. @MainActor protocol SuggestionLowPowerModeProviding: AnyObject { var isLowPowerModeEnabled: Bool { get } - var isLowPowerModeEnabledPublisher: AnyPublisher { get } + var lowPowerModeChanges: AnyPublisher { get } } @MainActor diff --git a/Cotabby/Services/Power/LowPowerModeMonitor.swift b/Cotabby/Services/Power/LowPowerModeMonitor.swift index 9b4ed5d3..b601571b 100644 --- a/Cotabby/Services/Power/LowPowerModeMonitor.swift +++ b/Cotabby/Services/Power/LowPowerModeMonitor.swift @@ -4,12 +4,16 @@ import Foundation /// Tracks whether macOS Low Power Mode is currently enabled and publishes changes so power-aware /// features (such as automatically pausing autocomplete to save battery) can react live. /// +/// `CotabbyAppEnvironment` owns one monitor for the process lifetime. The monitor reads the current +/// `ProcessInfo` value synchronously, then turns Foundation notifications into a changes-only stream +/// consumed by `SuggestionCoordinator`; it stays separate from suggestion orchestration so the OS +/// observation boundary can be shared without duplicating observers. +/// /// Lives on the main actor because `@Published` feeds SwiftUI and `SuggestionCoordinator`'s gating /// logic, and `NSProcessInfoPowerStateDidChange` is delivered on an unspecified thread — the /// subscription below hops back to main before touching `@Published` state. Distinct from /// `PowerSourceMonitor` (which tracks AC vs. battery via IOKit): Low Power Mode is a separate OS-level -/// toggle the user (or the system, automatically below 20% battery) can flip independently of whether -/// the Mac is plugged in. +/// policy that can change independently of whether the Mac is plugged in. @MainActor final class LowPowerModeMonitor: ObservableObject { @Published private(set) var isLowPowerModeEnabled: Bool @@ -40,14 +44,23 @@ final class LowPowerModeMonitor: ObservableObject { } func refreshLowPowerModeState() { - isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + let latestValue = ProcessInfo.processInfo.isLowPowerModeEnabled + guard isLowPowerModeEnabled != latestValue else { + return + } + + isLowPowerModeEnabled = latestValue } } extension LowPowerModeMonitor: SuggestionLowPowerModeProviding { - /// The coordinator subscribes through this erased publisher so it can depend on - /// `SuggestionLowPowerModeProviding` instead of this type's concrete `@Published` storage. - var isLowPowerModeEnabledPublisher: AnyPublisher { - $isLowPowerModeEnabled.eraseToAnyPublisher() + /// `@Published` begins with the current value. The coordinator already reads that value through + /// `isLowPowerModeEnabled`, so this boundary drops the bootstrap emission and exposes only later + /// changes. `removeDuplicates` also protects against redundant Foundation notifications. + var lowPowerModeChanges: AnyPublisher { + $isLowPowerModeEnabled + .dropFirst() + .removeDuplicates() + .eraseToAnyPublisher() } } diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift index 07ee5fc0..1ce2b3dd 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Power.swift @@ -20,8 +20,7 @@ extension EngineAndModelPaneView { ) { SettingsRowLabel( title: "Pause in Low Power Mode", - description: "Automatically pause suggestions while your Mac is in Low Power " + - "Mode, so Cotabby doesn't work against its battery-saving throttling.", + description: "Pause suggestions while Low Power Mode is on to reduce battery use.", systemImage: "bolt.slash.circle" ) } diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift index ca09eb6e..df6cf834 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorAcceptanceTests.swift @@ -541,7 +541,7 @@ private final class StubSuggestionLowPowerModeProvider: SuggestionLowPowerModePr private let subject = PassthroughSubject() - var isLowPowerModeEnabledPublisher: AnyPublisher { + var lowPowerModeChanges: AnyPublisher { subject.eraseToAnyPublisher() } } diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift index 2a31507d..2f21a13d 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift @@ -309,28 +309,44 @@ final class SuggestionCoordinatorInputTests: XCTestCase { // MARK: - Low Power Mode changes - func test_lowPowerModeChange_disablesPipelineWhenActiveAndAutoDisableEnabled() { + func test_lowPowerModePublisher_disablesPipelineWhenActiveAndAutoDisableEnabled() { // The fixture default (`isLowPowerModeAutoDisableEnabled: true`) mirrors production. let rig = retained(makeCoordinatorRig()) - rig.lowPowerModeProvider.isLowPowerModeEnabled = true - rig.coordinator.handleLowPowerModeChange() + rig.lowPowerModeProvider.setLowPowerModeEnabled(true) guard case let .disabled(reason) = rig.coordinator.state else { return XCTFail("Expected disabled, got \(rig.coordinator.state)") } XCTAssertEqual(reason, "Cotabby is paused because Low Power Mode is on.") + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) } - func test_lowPowerModeChange_leavesPipelineRunningWhenAutoDisableOptedOut() { + func test_lowPowerModePublisher_leavesPipelineRunningWhenAutoDisableOptedOut() { let rig = retained(makeCoordinatorRig( settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(isLowPowerModeAutoDisableEnabled: false) )) - rig.lowPowerModeProvider.isLowPowerModeEnabled = true - rig.coordinator.handleLowPowerModeChange() + rig.lowPowerModeProvider.setLowPowerModeEnabled(true) - XCTAssertEqual(rig.coordinator.state, .idle, "Opting out must keep autocomplete running in Low Power Mode") + XCTAssertEqual( + rig.coordinator.state, + .idle, + "Opting out must keep autocomplete running in Low Power Mode" + ) + } + + func test_lowPowerModePublisher_reenablesPipelineWhenModeTurnsOff() { + let rig = retained(makeCoordinatorRig()) + rig.lowPowerModeProvider.setLowPowerModeEnabled(true) + + rig.lowPowerModeProvider.setLowPowerModeEnabled(false) + + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertFalse( + rig.visualContext.startedSessions.isEmpty, + "Leaving Low Power Mode should resume context capture" + ) } func test_suppressedSyntheticInput_logsWithoutMutatingState() { diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift index 68a31bfe..ff3e516d 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift @@ -122,8 +122,13 @@ final class SuggestionCoordinatorLifecycleTests: XCTestCase { /// while the Mac is already in Low Power Mode must not restart generation, exactly like the /// global-off and paused cases it shares a gating tier with. func test_settingsChange_autoDisablingWhileAlreadyInLowPowerModeDoesNotRestartThePipeline() { - let rig = retained(makeCoordinatorRig()) - rig.lowPowerModeProvider.isLowPowerModeEnabled = true + let rig = retained(makeCoordinatorRig( + lowPowerModeEnabled: true, + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + isLowPowerModeAutoDisableEnabled: false, + debounceMilliseconds: 1 + ) + )) rig.coordinator.handleSuggestionSettingsChange( CotabbyTestFixtures.settingsSnapshot( diff --git a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift index b3ab5c5c..c14f954c 100644 --- a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift +++ b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift @@ -28,13 +28,28 @@ final class RigPermissionProvider: SuggestionPermissionProviding { @MainActor final class RigLowPowerModeProvider: SuggestionLowPowerModeProviding { - var isLowPowerModeEnabled = false + private(set) var isLowPowerModeEnabled: Bool - let subject = PassthroughSubject() + private let subject = PassthroughSubject() - var isLowPowerModeEnabledPublisher: AnyPublisher { + init(isLowPowerModeEnabled: Bool = false) { + self.isLowPowerModeEnabled = isLowPowerModeEnabled + } + + var lowPowerModeChanges: AnyPublisher { subject.eraseToAnyPublisher() } + + /// Updates both halves of the provider contract so tests exercise the same live subscription + /// path as production instead of calling the coordinator's event handler directly. + func setLowPowerModeEnabled(_ enabled: Bool) { + guard isLowPowerModeEnabled != enabled else { + return + } + + isLowPowerModeEnabled = enabled + subject.send(enabled) + } } @MainActor @@ -234,6 +249,7 @@ func makeCoordinatorRig( snapshot: FocusedInputSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello"), capability: FocusCapability = .supported, overlayState: OverlayState = .hidden(reason: "initial"), + lowPowerModeEnabled: Bool = false, settingsSnapshot: SuggestionSettingsSnapshot = CotabbyTestFixtures.settingsSnapshot(debounceMilliseconds: 1) ) -> CoordinatorRig { let focusSnapshot = FocusSnapshot( @@ -243,7 +259,7 @@ func makeCoordinatorRig( context: snapshot ) let permissionProvider = RigPermissionProvider() - let lowPowerModeProvider = RigLowPowerModeProvider() + let lowPowerModeProvider = RigLowPowerModeProvider(isLowPowerModeEnabled: lowPowerModeEnabled) let focusProvider = RigFocusProvider(snapshot: focusSnapshot) let inputMonitor = RigInputMonitor() let overlayController = RigOverlayController(state: overlayState) From d855c9cd036294bbafd8598c5cdfb6e7de3076ae Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:15:51 -0400 Subject: [PATCH 3/3] refactor: simplify low power mode code and prose --- .../SuggestionCoordinator+Input.swift | 3 +- .../Suggestion/SuggestionCoordinator.swift | 3 +- Cotabby/App/Core/CotabbyAppEnvironment.swift | 4 +-- .../Settings/SuggestionSettingsData.swift | 4 +-- .../Settings/SuggestionSettingsModel.swift | 8 ++--- .../Suggestion/SuggestionEngineModels.swift | 4 +-- .../SuggestionSubsystemContracts.swift | 4 +-- .../Services/Power/LowPowerModeMonitor.swift | 25 +++++----------- .../Settings/SuggestionSettingsStore.swift | 4 +-- .../SuggestionAvailabilityEvaluator.swift | 29 +++---------------- Cotabby/UI/Settings/SettingsIndex.swift | 5 ++-- .../SuggestionCoordinatorInputTests.swift | 9 ++---- .../SuggestionCoordinatorLifecycleTests.swift | 3 -- .../SuggestionSettingsModelTests.swift | 4 +-- .../SuggestionSettingsStoreTests.swift | 2 -- ...SuggestionAvailabilityEvaluatorTests.swift | 17 ++++------- .../SuggestionCoordinatorTestSupport.swift | 3 +- 17 files changed, 33 insertions(+), 98 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index 762b408b..040ea482 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -35,8 +35,7 @@ extension SuggestionCoordinator { } } - /// Reconciles coordinator state whenever macOS Low Power Mode toggles, so autocomplete pauses or - /// resumes live without waiting for the next keystroke or focus change to notice. + /// Re-evaluates the pipeline immediately instead of waiting for another input or focus event. func handleLowPowerModeChange() { CotabbyLogger.suggestion.debug("Low Power Mode state changed, reconciling") reconcileWithCurrentEnvironment() diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index c8ff7a48..bc321eb0 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -24,8 +24,7 @@ final class SuggestionCoordinator: ObservableObject { // Core collaborators. The coordinator depends on capability-shaped protocols here so its // orchestration logic stays separated from concrete service implementations. let permissionManager: any SuggestionPermissionProviding - /// Live macOS Low Power Mode state. Read (alongside the matching settings toggle) by the - /// availability gate so predictions and visual context both pause automatically to save battery. + /// Provides the initial Low Power Mode state and subsequent transitions. let lowPowerModeProvider: any SuggestionLowPowerModeProviding let focusModel: any SuggestionFocusProviding let inputMonitor: any SuggestionInputMonitoring diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 94dc38ef..8d40b237 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -24,9 +24,7 @@ final class CotabbyAppEnvironment { let openAICompatibleConnectionModel: OpenAICompatibleConnectionModel let foundationModelAvailabilityService: FoundationModelAvailabilityService let powerSourceMonitor: PowerSourceMonitor - /// Owned here (not just inside `SuggestionCoordinator`) for the same reason as - /// `powerSourceMonitor`: a single long-lived instance shared for the app's lifetime rather than - /// re-created per observer. + /// Process-lifetime Low Power Mode observer shared with suggestion orchestration. let lowPowerModeMonitor: LowPowerModeMonitor /// Detects when a composing input method (Japanese kana, Chinese pinyin, Korean hangul, ...) is /// active so `SuggestionInserter` commits accepted text through an IME-safe path instead of a diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index 630778fc..3a701198 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -11,9 +11,7 @@ struct SuggestionGeneralSettings: Equatable { var disabledAppRules: [DisabledApplicationRule] var suggestInIntegratedTerminals: Bool var isPerformanceTrackingEnabled: Bool - /// When true (the default), Cotabby automatically pauses suggestions while macOS Low Power Mode - /// is on, so generation does not fight the OS's own battery-saving throttling. Read alongside the - /// live `LowPowerModeMonitor` state by `SuggestionAvailabilityEvaluator`. + /// Whether suggestions pause while Low Power Mode is active. var isLowPowerModeAutoDisableEnabled: Bool } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 3281f2bf..13eb891d 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -86,9 +86,7 @@ final class SuggestionSettingsModel: ObservableObject { /// default user never pays any extra storage or write cost — recording only kicks in once the /// user opts in from Settings. @Published private(set) var isPerformanceTrackingEnabled: Bool - /// Whether Cotabby automatically pauses suggestions while macOS Low Power Mode is on. Defaults to - /// true so a fresh install saves battery out of the box; read live alongside `LowPowerModeMonitor` - /// by `SuggestionAvailabilityEvaluator`. + /// UI-facing preference for pausing suggestions in Low Power Mode. @Published private(set) var isLowPowerModeAutoDisableEnabled: Bool /// Whether Cotabby's status item is inserted into the menu bar. The process and suggestion /// pipeline remain active when hidden; launching the app again opens Settings as the recovery path. @@ -1378,9 +1376,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $customWordCountLowWords, $customWordCountHighWords ) - // `extendedContext` shares its outer slot with `suggestInIntegratedTerminals`, - // `isSurfaceContextEnabled`, and `isLowPowerModeAutoDisableEnabled` via one grouped - // `CombineLatest4` so new toggles cost no extra top-level slot (the outer is at the cap). + // The outer `CombineLatest4` is full, so these settings share its grouped publisher slot. return Publishers.CombineLatest4( primary, $acceptanceGranularity, diff --git a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift index 96d9b182..5914784e 100644 --- a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift +++ b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift @@ -110,9 +110,7 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// xterm.js surfaces). Power users can opt back in. Travels in the snapshot so the availability /// gate sees the live value alongside the other "where Cotabby runs" rules. let suggestInIntegratedTerminals: Bool - /// When true (the default), the availability gate suppresses suggestions while macOS Low Power - /// Mode is on (see `LowPowerModeMonitor`). Travels in the snapshot so the gate reads the live - /// setting value alongside the other "where Cotabby runs" rules. + /// Whether the availability gate pauses suggestions during Low Power Mode. let isLowPowerModeAutoDisableEnabled: Bool let selectedEngine: SuggestionEngineKind let selectedWordCountPreset: SuggestionWordCountPreset diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index 80250be2..ab8c5b05 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -22,9 +22,7 @@ protocol SuggestionPermissionProviding: AnyObject { var screenRecordingGrantedPublisher: AnyPublisher { get } } -/// Live OS-level Low Power Mode state: a synchronous read for the initial gate plus a changes-only -/// stream for later transitions. Keeping those roles explicit prevents a subscriber from having to -/// guess whether the publisher begins with a bootstrap value or its first real OS change. +/// Supplies the initial Low Power Mode value separately from its changes-only stream. @MainActor protocol SuggestionLowPowerModeProviding: AnyObject { var isLowPowerModeEnabled: Bool { get } diff --git a/Cotabby/Services/Power/LowPowerModeMonitor.swift b/Cotabby/Services/Power/LowPowerModeMonitor.swift index b601571b..7c47d260 100644 --- a/Cotabby/Services/Power/LowPowerModeMonitor.swift +++ b/Cotabby/Services/Power/LowPowerModeMonitor.swift @@ -1,19 +1,13 @@ import Combine import Foundation -/// Tracks whether macOS Low Power Mode is currently enabled and publishes changes so power-aware -/// features (such as automatically pausing autocomplete to save battery) can react live. +/// Bridges `ProcessInfo.isLowPowerModeEnabled` into the suggestion pipeline. /// -/// `CotabbyAppEnvironment` owns one monitor for the process lifetime. The monitor reads the current -/// `ProcessInfo` value synchronously, then turns Foundation notifications into a changes-only stream -/// consumed by `SuggestionCoordinator`; it stays separate from suggestion orchestration so the OS -/// observation boundary can be shared without duplicating observers. +/// `CotabbyAppEnvironment` owns one instance for the process lifetime. The coordinator reads its +/// initial state synchronously and subscribes to later transitions. Main-actor isolation protects +/// `@Published` state because Foundation does not guarantee the notification's delivery queue. /// -/// Lives on the main actor because `@Published` feeds SwiftUI and `SuggestionCoordinator`'s gating -/// logic, and `NSProcessInfoPowerStateDidChange` is delivered on an unspecified thread — the -/// subscription below hops back to main before touching `@Published` state. Distinct from -/// `PowerSourceMonitor` (which tracks AC vs. battery via IOKit): Low Power Mode is a separate OS-level -/// policy that can change independently of whether the Mac is plugged in. +/// This remains separate from `PowerSourceMonitor`, which tracks AC power rather than Low Power Mode. @MainActor final class LowPowerModeMonitor: ObservableObject { @Published private(set) var isLowPowerModeEnabled: Bool @@ -23,9 +17,7 @@ final class LowPowerModeMonitor: ObservableObject { init() { isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled - // `NSProcessInfoPowerStateDidChange` does not guarantee a delivery queue, unlike - // `NSWorkspace` notifications elsewhere in this folder, so this explicitly targets the main - // queue rather than assuming the callback is already there. + // Foundation does not guarantee a delivery queue, so observe on the main queue. powerStateObserver = NotificationCenter.default.addObserver( forName: .NSProcessInfoPowerStateDidChange, object: nil, @@ -43,6 +35,7 @@ final class LowPowerModeMonitor: ObservableObject { } } + /// Refreshes the current value without emitting duplicate state changes. func refreshLowPowerModeState() { let latestValue = ProcessInfo.processInfo.isLowPowerModeEnabled guard isLowPowerModeEnabled != latestValue else { @@ -54,9 +47,7 @@ final class LowPowerModeMonitor: ObservableObject { } extension LowPowerModeMonitor: SuggestionLowPowerModeProviding { - /// `@Published` begins with the current value. The coordinator already reads that value through - /// `isLowPowerModeEnabled`, so this boundary drops the bootstrap emission and exposes only later - /// changes. `removeDuplicates` also protects against redundant Foundation notifications. + /// Omits `@Published`'s bootstrap emission because the protocol exposes initial state separately. var lowPowerModeChanges: AnyPublisher { $isLowPowerModeEnabled .dropFirst() diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 70d0861a..31807a36 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -337,9 +337,7 @@ struct SuggestionSettingsStore { // in from the Performance pane. let resolvedPerformanceTrackingEnabled = userDefaults.object(forKey: Self.performanceTrackingEnabledDefaultsKey) as? Bool ?? false - // Defaults to true: a fresh install pauses autocomplete under Low Power Mode out of the box - // to avoid fighting the OS's own battery-saving throttling. Existing installs (no key) get - // the same, matching the issue's ask for this to be automatic with an opt-out. + // Existing installs lack this key; defaulting to true keeps the feature opt-out. let resolvedLowPowerModeAutoDisableEnabled = userDefaults.object(forKey: Self.lowPowerModeAutoDisableDefaultsKey) as? Bool ?? true // Existing installs keep the status item unless the user explicitly hides it. Hiding the diff --git a/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift b/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift index 34b447f0..c437980a 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionAvailabilityEvaluator.swift @@ -27,13 +27,7 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is temporarily paused." } - // Same tier as the two checks above: a full suppression the user opted into, rather than a - // per-app/per-domain/per-field rule. Gated behind its own toggle (default on) so a user who - // wants autocomplete regardless of battery impact can opt back out. - guard !isSuppressedByLowPowerMode( - isActive: isLowPowerModeActive, - autoDisableEnabled: isLowPowerModeAutoDisableEnabled - ) else { + if isLowPowerModeActive && isLowPowerModeAutoDisableEnabled { return "Cotabby is paused because Low Power Mode is on." } @@ -147,16 +141,7 @@ enum SuggestionAvailabilityEvaluator { return SuggestionRequestFactory.shouldGenerateSuggestion(for: context.precedingText) } - /// Whether the Low Power Mode gate should suppress autocomplete: the Mac must actually be in Low - /// Power Mode, and the user must not have opted out of the auto-pause behavior. Broken out of - /// `disabledReason` to keep that function's branch count readable as gates accumulate. - private static func isSuppressedByLowPowerMode(isActive: Bool, autoDisableEnabled: Bool) -> Bool { - isActive && autoDisableEnabled - } - - /// The "where Cotabby runs" rules: per-app disable, per-site disable, standalone terminal apps, - /// and integrated terminals. Broken out of `disabledReason` (same behavior, same order) purely to - /// keep that function's branch count readable as gates accumulate. + /// Returns the first app, domain, or terminal rule that disables the focused field. private static func locationDisabledReason( disabledAppBundleIdentifiers: Set, disabledDomains: Set, @@ -168,10 +153,7 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is disabled in \(focusSnapshot.applicationName)." } - // Per-site disable: when focus capture resolved a page URL, a host on the user's disabled list - // (exact or parent domain) suppresses autocomplete the same way a disabled app does. The URL is - // nil unless the feature is enabled and a browser exposed it, and the list is empty by default, - // so non-browser focus is unaffected. + // Only focus snapshots with a resolved browser URL can match disabled domains. if let urlString = focusSnapshot.context?.focusedURLString, let host = BrowserDomain.host(fromURLString: urlString), BrowserDomain.isHostDisabled(host, disabledDomains: disabledDomains) { @@ -182,10 +164,7 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is not available in terminal apps." } - // Integrated terminals (VS Code / Cursor xterm.js) share their app's bundle id with the - // editor and chat, so they slip past the blocklist above. Suppress them here unless the user - // has opted back in, keeping ghost text out of shell prompts and command output while the - // editor and Copilot chat in the same window keep suggesting. + // Integrated terminals share the editor's bundle ID, so check their AX-derived flag. if !suggestInIntegratedTerminals, focusSnapshot.context?.isIntegratedTerminal == true { return "Cotabby is not available in the integrated terminal." } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index bc7bb534..68862262 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -327,7 +327,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." case .modelStatus: return "Whether the local model is loaded and ready." case .selectedModel: return "Which downloaded model generates suggestions." - case .lowPowerModeAutoDisable: return "Pause suggestions automatically to save battery." + case .lowPowerModeAutoDisable: return "Pause suggestions while Low Power Mode is active." case .powerBasedModelSwitching: return "Use a different engine or model by power source." case .batteryModel: return "Engine and model used while on battery." case .pluggedInModel: return "Engine and model used while plugged in." @@ -490,8 +490,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { return ["model", "gguf", "pick", "selected", "active model", "choose model", "current model", "default model"] case .lowPowerModeAutoDisable: - return ["low power mode", "power saving", "battery saver", "pause", "disable", - "throttle", "conserve battery", "power", "automatic", "auto disable"] + return ["low power mode", "battery saver", "pause", "power", "automatic", "auto disable"] case .powerBasedModelSwitching: return ["power", "battery", "plugged", "energy", "ac", "charger", "switch model", "power source", "auto switch", "adaptive", diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift index 2f21a13d..904eea7e 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorInputTests.swift @@ -310,7 +310,6 @@ final class SuggestionCoordinatorInputTests: XCTestCase { // MARK: - Low Power Mode changes func test_lowPowerModePublisher_disablesPipelineWhenActiveAndAutoDisableEnabled() { - // The fixture default (`isLowPowerModeAutoDisableEnabled: true`) mirrors production. let rig = retained(makeCoordinatorRig()) rig.lowPowerModeProvider.setLowPowerModeEnabled(true) @@ -329,11 +328,7 @@ final class SuggestionCoordinatorInputTests: XCTestCase { rig.lowPowerModeProvider.setLowPowerModeEnabled(true) - XCTAssertEqual( - rig.coordinator.state, - .idle, - "Opting out must keep autocomplete running in Low Power Mode" - ) + XCTAssertEqual(rig.coordinator.state, .idle) } func test_lowPowerModePublisher_reenablesPipelineWhenModeTurnsOff() { @@ -345,7 +340,7 @@ final class SuggestionCoordinatorInputTests: XCTestCase { XCTAssertEqual(rig.coordinator.state, .idle) XCTAssertFalse( rig.visualContext.startedSessions.isEmpty, - "Leaving Low Power Mode should resume context capture" + "Expected visual context to restart" ) } diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift index ff3e516d..ee4d34f6 100644 --- a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorLifecycleTests.swift @@ -118,9 +118,6 @@ final class SuggestionCoordinatorLifecycleTests: XCTestCase { XCTAssertEqual(rig.visualContext.cancelCalls, [true]) } - /// Mirrors `test_settingsChange_pausingDoesNotRestartThePipeline`: a settings change that lands - /// while the Mac is already in Low Power Mode must not restart generation, exactly like the - /// global-off and paused cases it shares a gating tier with. func test_settingsChange_autoDisablingWhileAlreadyInLowPowerModeDoesNotRestartThePipeline() { let rig = retained(makeCoordinatorRig( lowPowerModeEnabled: true, diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift index e3cf2b6d..aae98636 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift @@ -631,9 +631,7 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(snapshots.last?.customWordCountRange, SuggestionWordRange(lowWords: 2, highWords: 50)) XCTAssertEqual(snapshots.last?.isUsingCustomWordCountRange, true) - // Regression guard for the grouped publisher this setting shares with `extendedContext` and - // `suggestInIntegratedTerminals`: a miswired tuple destructure would silently carry a stale - // or swapped value instead of failing to compile. + // The fourth grouped publisher must map to the matching snapshot field. XCTAssertEqual(snapshots.last?.isLowPowerModeAutoDisableEnabled, true) model.setLowPowerModeAutoDisableEnabled(false) XCTAssertEqual(snapshots.last?.isLowPowerModeAutoDisableEnabled, false) diff --git a/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift b/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift index 331d5de3..49f1a079 100644 --- a/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/Support/Settings/SuggestionSettingsStoreTests.swift @@ -156,8 +156,6 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertTrue(data.isMenuBarIconVisible) } - /// A fresh install pauses autocomplete in Low Power Mode out of the box, matching the issue's - /// ask for this to be automatic with an opt-out rather than opt-in. func test_load_lowPowerModeAutoDisableDefaultsOn() async { let defaults = makeIsolatedDefaults() diff --git a/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift index 5846411b..d37923de 100644 --- a/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/Support/Suggestion/Request/SuggestionAvailabilityEvaluatorTests.swift @@ -146,7 +146,7 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { // MARK: - Low Power Mode gating - func test_disabledReason_whenLowPowerModeActiveAndAutoDisableEnabled_returnsFixedCopy() { + func test_disabledReason_whenLowPowerModeActiveAndAutoDisableEnabled_returnsLowPowerReason() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, isLowPowerModeActive: true, @@ -158,8 +158,6 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertEqual(reason, "Cotabby is paused because Low Power Mode is on.") } - /// The user opted out of auto-pausing: Low Power Mode being active must not suppress - /// suggestions when the toggle is off. func test_disabledReason_whenLowPowerModeActiveButAutoDisableOptedOut_returnsNil() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, @@ -172,7 +170,6 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertNil(reason, "Opting out should let suggestions run in Low Power Mode") } - /// The setting is on, but the Mac is not currently in Low Power Mode: nothing should suppress. func test_disabledReason_whenAutoDisableEnabledButLowPowerModeInactive_returnsNil() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, @@ -185,9 +182,7 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertNil(reason) } - /// Same tier as the global-off and temporarily-paused checks: Low Power Mode should win over a - /// per-app disable reason so the user sees the more fundamental cause. - func test_disabledReason_lowPowerMode_winsOverAppDisabled() { + func test_disabledReason_lowPowerModeTakesPrecedenceOverDisabledApp() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, isLowPowerModeActive: true, @@ -201,7 +196,7 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { } func test_shouldSchedulePrediction_falseWhenLowPowerModeActiveAndAutoDisableEnabled() { - let ok = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + let shouldSchedule = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: true, isLowPowerModeActive: true, isLowPowerModeAutoDisableEnabled: true, @@ -209,11 +204,11 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { focusSnapshot: makeSnapshot(capability: .supported) ) - XCTAssertFalse(ok) + XCTAssertFalse(shouldSchedule) } func test_shouldCaptureVisualContext_falseWhenLowPowerModeActiveAndAutoDisableEnabled() { - let ok = SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( + let shouldCapture = SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: true, isLowPowerModeActive: true, isLowPowerModeAutoDisableEnabled: true, @@ -223,7 +218,7 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { isFastModeEnabled: false ) - XCTAssertFalse(ok) + XCTAssertFalse(shouldCapture) } func test_disabledReason_whenFocusedDomainIsDisabled_returnsSiteReason() { diff --git a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift index c14f954c..e4cba119 100644 --- a/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift +++ b/CotabbyTests/TestSupport/SuggestionCoordinatorTestSupport.swift @@ -40,8 +40,7 @@ final class RigLowPowerModeProvider: SuggestionLowPowerModeProviding { subject.eraseToAnyPublisher() } - /// Updates both halves of the provider contract so tests exercise the same live subscription - /// path as production instead of calling the coordinator's event handler directly. + /// Updates current state before publishing its matching transition. func setLowPowerModeEnabled(_ enabled: Bool) { guard isLowPowerModeEnabled != enabled else { return