From 73f28a1589cfd820fe6616bae34e58e7af771ead Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:33:43 -0400 Subject: [PATCH 1/5] add temporary pause handling to suggestions --- Cotabby.xcodeproj/project.pbxproj | 10 ++ .../SuggestionCoordinator+Acceptance.swift | 4 + .../SuggestionCoordinator+Input.swift | 3 + .../SuggestionCoordinator+Lifecycle.swift | 2 + .../SuggestionCoordinator+Prediction.swift | 1 + Cotabby/App/Core/AppDelegate.swift | 21 +++- Cotabby/App/Core/CotabbyAppEnvironment.swift | 8 +- Cotabby/Models/SuggestionEngineModels.swift | 2 + Cotabby/Models/SuggestionPauseModels.swift | 97 +++++++++++++++++++ Cotabby/Models/SuggestionSettingsData.swift | 2 + Cotabby/Models/SuggestionSettingsModel.swift | 79 ++++++++++++++- .../SuggestionAvailabilityEvaluator.swift | 9 ++ Cotabby/Support/SuggestionSettingsStore.swift | 19 ++++ Cotabby/UI/MenuBarStatusLabelView.swift | 6 ++ Cotabby/UI/MenuBarView.swift | 47 ++++++--- CotabbyTests/CotabbyTestFixtures.swift | 2 + ...SuggestionAvailabilityEvaluatorTests.swift | 11 +++ ...SuggestionCoordinatorAcceptanceTests.swift | 29 ++++++ .../SuggestionCoordinatorLifecycleTests.swift | 15 +++ CotabbyTests/SuggestionPauseModelsTests.swift | 49 ++++++++++ .../SuggestionSettingsModelTests.swift | 10 ++ .../SuggestionSettingsStoreTests.swift | 20 ++++ 22 files changed, 426 insertions(+), 20 deletions(-) create mode 100644 Cotabby/Models/SuggestionPauseModels.swift create mode 100644 CotabbyTests/SuggestionPauseModelsTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 90aeb2ca..16b94a85 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ 1D5389E9562AF6315BFCDCE1 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; 1D54C941ED086C6427CFD773 /* MacroEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974A8708D2006767BD76862A /* MacroEngine.swift */; }; 1DF03DC12D45C22753F683BC /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */; }; + 1DF1A9DFF50E9219875E24FF /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */; }; 1E0796CADFB4F561EC94602A /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; 1EB90D3D8A5BA028B86E4D9F /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; 1F714771EE793A5AF92F1E2B /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51020F8CD58338BD643FBF63 /* ModelDownloadManager.swift */; }; @@ -234,6 +235,7 @@ 4CCF29A7EA1B7D37841C135D /* DateMacroEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313EDBA60565836F32CEEC10 /* DateMacroEvaluatorTests.swift */; }; 4D583CB3DA253FB795EE54F9 /* ArithmeticEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */; }; 4E2DEFF3CA51E8B160B802CA /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; + 4E49547F22DC8663171E3160 /* SuggestionPauseModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224494DA9610B169F1C5BCFF /* SuggestionPauseModelsTests.swift */; }; 4E7AC9EBE2781AC49E167608 /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */; }; 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */; }; 4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */; }; @@ -374,6 +376,7 @@ 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */; }; 7E99F5676A1D1DF7EA7D7702 /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */; }; + 7EB2CB1000EA2407A2F59B4E /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */; }; 7EEE6AEBFBD419FFE7C544BA /* SuggestionSettingsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93563FDA25DFC0038E5F887 /* SuggestionSettingsData.swift */; }; 7FC103944F4EF39DB965F469 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = 88921938DC814625ED57D605 /* InMemoryLogging */; }; 814E348C663B697537594F0C /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */; }; @@ -769,6 +772,7 @@ 210F9AD332273FE2EB3A9A01 /* WebContentFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetectorTests.swift; sourceTree = ""; }; 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; + 224494DA9610B169F1C5BCFF /* SuggestionPauseModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPauseModelsTests.swift; sourceTree = ""; }; 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = ""; }; 22707E26E2106DF0E826D32D /* ControlTokenMarkersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkersTests.swift; sourceTree = ""; }; 22BE47D1DBF6C23151458836 /* MacroTriggerStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroTriggerStateMachineTests.swift; sourceTree = ""; }; @@ -1030,6 +1034,7 @@ C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; C2AD7D366EC4344D332EE6A3 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; + C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPauseModels.swift; sourceTree = ""; }; C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorAcceptanceTests.swift; sourceTree = ""; }; C379D77029D6E88C8C1B9AF7 /* emoji.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = emoji.json; sourceTree = ""; }; C3A35FAA742408D002B75920 /* WebContentFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetector.swift; sourceTree = ""; }; @@ -1402,6 +1407,7 @@ F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */, ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */, 386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */, + C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */, 81718CA62FBC775A6CEBCED1 /* SuggestionQualityMetricsStore.swift */, D93563FDA25DFC0038E5F887 /* SuggestionSettingsData.swift */, 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */, @@ -1583,6 +1589,7 @@ 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */, 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */, CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */, + 224494DA9610B169F1C5BCFF /* SuggestionPauseModelsTests.swift */, B4CC566AC1DE33FD0CD30E1E /* SuggestionQualityMetricsStoreTests.swift */, EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */, 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */, @@ -2225,6 +2232,7 @@ 7DEFC57991AB0C5379AD9CBF /* SuggestionModels.swift in Sources */, EE2C9177CE615298595215A8 /* SuggestionOverlayPresenter.swift in Sources */, DFF3AA49E0770DE3CFBC24C1 /* SuggestionOverlayStabilityGate.swift in Sources */, + 7EB2CB1000EA2407A2F59B4E /* SuggestionPauseModels.swift in Sources */, 18680D0D66469A2954A50B6C /* SuggestionQualityMetricsStore.swift in Sources */, B691B8378FD73E186A72450C /* SuggestionRequestFactory.swift in Sources */, 532283A7651F7E66635F4281 /* SuggestionSessionReconciler.swift in Sources */, @@ -2475,6 +2483,7 @@ 0AF568AB234033BA2DE4CAA7 /* SuggestionModels.swift in Sources */, 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */, 0F3267956257401F39386773 /* SuggestionOverlayStabilityGate.swift in Sources */, + 1DF1A9DFF50E9219875E24FF /* SuggestionPauseModels.swift in Sources */, 5687320132AD97B4086260DF /* SuggestionQualityMetricsStore.swift in Sources */, 46F341472191BC451B6BF6B5 /* SuggestionRequestFactory.swift in Sources */, CA5B2D226FBAA5419E78F14F /* SuggestionSessionReconciler.swift in Sources */, @@ -2651,6 +2660,7 @@ 5CED06E89FBEF557DCD6C684 /* SuggestionFocusFreshnessTests.swift in Sources */, 6CBEF02FCDFCF406E378C27C /* SuggestionInteractionStateTests.swift in Sources */, 4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */, + 4E49547F22DC8663171E3160 /* SuggestionPauseModelsTests.swift in Sources */, 695E431AC3FF79769E2C5EEF /* SuggestionQualityMetricsStoreTests.swift in Sources */, B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */, 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 4fccaf7c..83e627da 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -26,6 +26,10 @@ extension SuggestionCoordinator { ) -> Bool { let snapshot = focusModel.snapshot + if let disabledReason = currentDisabledReason(focusSnapshot: snapshot) { + return passTabThrough(reason: disabledReason) + } + guard permissionManager.inputMonitoringGranted else { return passTabThrough( reason: "Input Monitoring permission is required before Cotabby can accept suggestions." diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index b0fbc7cd..ce66515f 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -22,6 +22,7 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -58,6 +59,7 @@ extension SuggestionCoordinator { if let context = snapshot.context, SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -87,6 +89,7 @@ extension SuggestionCoordinator { // earlier `shouldCaptureVisualContext` check already declined. if SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift index 8de0639f..f167521d 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift @@ -72,6 +72,7 @@ extension SuggestionCoordinator { if let focusedSnapshot = focusModel.snapshot.context, SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, @@ -85,6 +86,7 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index d479cd4f..5b83e269 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -990,6 +990,7 @@ extension SuggestionCoordinator { func currentDisabledReason(focusSnapshot: FocusSnapshot) -> String? { SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, + temporarilyPaused: settingsSnapshot.isTemporarilyPaused, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index f9b3221f..024d42f2 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -98,6 +98,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) + // Focus may stay on the same field while a menu action pauses or resumes Cotabby. React to + // settings snapshots too so the field-edge indicator cannot remain visible during a pause. + suggestionSettings.snapshotPublisher + .dropFirst() + .sink { [weak self] settings in + guard let self else { return } + self.updateActivationIndicator(for: self.focusModel.snapshot, settings: settings) + } + .store(in: &cancellables) + if let focusDebugOverlayController { focusModel.$latestPollEvent .compactMap { $0 } @@ -226,9 +236,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Shows or hides the field-edge Cotabby icon based on focus state, global enable, per-app /// disable rules, and the user's indicator toggle. - private func updateActivationIndicator(for snapshot: FocusSnapshot) { - guard suggestionSettings.isGloballyEnabled, - !suggestionSettings.isApplicationDisabled(bundleIdentifier: snapshot.bundleIdentifier), + private func updateActivationIndicator( + for snapshot: FocusSnapshot, + settings: SuggestionSettingsSnapshot? = nil + ) { + let settings = settings ?? suggestionSettings.snapshot + guard settings.isGloballyEnabled, + !settings.isTemporarilyPaused, + !settings.disabledAppBundleIdentifiers.contains(snapshot.bundleIdentifier ?? ""), case .supported = snapshot.capability, let context = snapshot.context else { diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index da6d15d4..d0f7af04 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -85,7 +85,9 @@ final class CotabbyAppEnvironment { // complete inside its own UI; the focus tracker recognises it by this AX identifier. selfCaptureAllowedElementIdentifier: ContextLivePreview.accessibilityIdentifier, isCaptureSuppressedForBundle: { bundleIdentifier in - guard suggestionSettings.isGloballyEnabled else { return true } + guard suggestionSettings.isGloballyEnabled, + !suggestionSettings.isTemporarilyPaused + else { return true } if let bundleIdentifier, suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleIdentifier) { return true @@ -100,7 +102,9 @@ final class CotabbyAppEnvironment { // evaluate against the previous app's identity until the next AX poll fires. This // is the same race the downstream evaluator already has — not a new regression. inputMonitor.shouldProcessEventsProvider = { [weak focusModel] in - guard suggestionSettings.isGloballyEnabled else { return false } + guard suggestionSettings.isGloballyEnabled, + !suggestionSettings.isTemporarilyPaused + else { return false } guard let snapshot = focusModel?.snapshot else { return true } if calendarAccessibilityCaptureGuard.shouldSuppressCapture( for: snapshot.bundleIdentifier diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index 0770f35b..3e60be97 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -93,6 +93,8 @@ enum AcceptanceGranularity: String, CaseIterable, Codable, Sendable { /// time. Keeping this as a value type makes change detection simple and deterministic. struct SuggestionSettingsSnapshot: Equatable, Sendable { let isGloballyEnabled: Bool + /// Resolved live pause state. Timed pauses publish `false` when their expiration fires. + let isTemporarilyPaused: Bool let disabledAppBundleIdentifiers: Set /// When false (the default), ghost text is suppressed in integrated terminals (VS Code / Cursor /// xterm.js surfaces). Power users can opt back in. Travels in the snapshot so the availability diff --git a/Cotabby/Models/SuggestionPauseModels.swift b/Cotabby/Models/SuggestionPauseModels.swift new file mode 100644 index 00000000..d819bd1f --- /dev/null +++ b/Cotabby/Models/SuggestionPauseModels.swift @@ -0,0 +1,97 @@ +import Foundation + +/// A menu-bar pause choice. Calendar math lives here so the UI only expresses intent and never has +/// to decide what "tomorrow" means. Calendar-based midnight also stays correct across DST changes. +nonisolated enum SuggestionPauseDuration: CaseIterable, Identifiable { + case fifteenMinutes + case thirtyMinutes + case oneHour + case untilTomorrow + case indefinitely + + var id: Self { self } + + var menuLabel: String { + switch self { + case .fifteenMinutes: return "Pause for 15 Minutes" + case .thirtyMinutes: return "Pause for 30 Minutes" + case .oneHour: return "Pause for 1 Hour" + case .untilTomorrow: return "Pause Until Tomorrow" + case .indefinitely: return "Pause Until I Turn It Back On" + } + } + + func pauseState( + from now: Date = Date(), + calendar: Calendar = .current + ) -> SuggestionPauseState { + switch self { + case .fifteenMinutes: + return .until(now.addingTimeInterval(15 * 60)) + case .thirtyMinutes: + return .until(now.addingTimeInterval(30 * 60)) + case .oneHour: + return .until(now.addingTimeInterval(60 * 60)) + case .untilTomorrow: + let today = calendar.startOfDay(for: now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) + ?? now.addingTimeInterval(24 * 60 * 60) + return .until(tomorrow) + case .indefinitely: + return .indefinitely + } + } +} + +/// Durable temporary-disable state shared by persistence, the suggestion pipeline, and menu UI. +/// +/// This remains separate from `isGloballyEnabled`: a pause is session intent with an optional end, +/// while the global preference remains the user's normal operating configuration. The settings +/// model owns this value for the app lifetime and clears timed values when they expire. +nonisolated enum SuggestionPauseState: Codable, Equatable, Sendable { + case until(Date) + case indefinitely + + var expirationDate: Date? { + guard case let .until(date) = self else { return nil } + return date + } + + func isActive(at date: Date = Date()) -> Bool { + switch self { + case let .until(expiration): + return expiration > date + case .indefinitely: + return true + } + } + + func activeState(at date: Date = Date()) -> SuggestionPauseState? { + isActive(at: date) ? self : nil + } + + func statusText( + at now: Date = Date(), + calendar: Calendar = .current + ) -> String? { + guard isActive(at: now) else { return nil } + + switch self { + case .indefinitely: + return "Paused until enabled" + case let .until(expiration): + let tomorrow = calendar.date( + byAdding: .day, + value: 1, + to: calendar.startOfDay(for: now) + ) + if let tomorrow, expiration == tomorrow { + return "Paused until tomorrow" + } + if calendar.isDate(expiration, inSameDayAs: now) { + return "Paused until \(expiration.formatted(date: .omitted, time: .shortened))" + } + return "Paused until \(expiration.formatted(date: .abbreviated, time: .shortened))" + } + } +} diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index f5b1afb9..6c05fdcc 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -12,6 +12,8 @@ import Foundation /// round-trip and so the store can compare resolved-versus-stored state. struct SuggestionSettingsData: Equatable { var isGloballyEnabled: Bool + /// Temporary suppression persists independently from the user's normal global preference. + var pauseState: SuggestionPauseState? var showIndicator: Bool var showAcceptanceHint: Bool var disabledAppRules: [DisabledApplicationRule] diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 5944bd89..a25f6c4d 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -34,6 +34,8 @@ enum ShortcutAction: CaseIterable { @MainActor final class SuggestionSettingsModel: ObservableObject { @Published private(set) var isGloballyEnabled: Bool + /// The settings model owns pause lifetime so views and services observe one shared state. + @Published private(set) var pauseState: SuggestionPauseState? @Published private(set) var showIndicator: Bool /// Whether the keycap hint (the small pill that teaches the accept key) is drawn after ghost text. @Published private(set) var showAcceptanceHint: Bool @@ -140,6 +142,7 @@ final class SuggestionSettingsModel: ObservableObject { /// Retained so `resetToDefaults` can re-resolve the same first-launch values the app shipped with /// (a few defaults — word-count preset, profile name, debounce/poll cadence — come from here). private let configuration: SuggestionConfiguration + private var pauseExpirationTimer: Timer? // Public default constants re-exported from `SuggestionSettingsStore` (the single source of // truth) so the Settings UI can keep referencing them as `SuggestionSettingsModel.X`. @@ -173,6 +176,7 @@ final class SuggestionSettingsModel: ObservableObject { self.configuration = configuration isGloballyEnabled = data.isGloballyEnabled + pauseState = data.pauseState showIndicator = data.showIndicator showAcceptanceHint = data.showAcceptanceHint disabledAppRules = data.disabledAppRules @@ -227,6 +231,7 @@ final class SuggestionSettingsModel: ObservableObject { batteryModelFilename = data.batteryModelFilename pluggedInEngine = data.pluggedInEngine pluggedInModelFilename = data.pluggedInModelFilename + schedulePauseExpirationIfNeeded() } /// Restores every preference this facade owns to its first-launch default and persists the reset. @@ -241,6 +246,7 @@ final class SuggestionSettingsModel: ObservableObject { let data = store.resetToDefaults(configuration: configuration) isGloballyEnabled = data.isGloballyEnabled + pauseState = data.pauseState showIndicator = data.showIndicator showAcceptanceHint = data.showAcceptanceHint disabledAppRules = data.disabledAppRules @@ -295,6 +301,7 @@ final class SuggestionSettingsModel: ObservableObject { batteryModelFilename = data.batteryModelFilename pluggedInEngine = data.pluggedInEngine pluggedInModelFilename = data.pluggedInModelFilename + schedulePauseExpirationIfNeeded() } /// Legacy compatibility shim. Reads through to `showIndicator`. @@ -305,6 +312,7 @@ final class SuggestionSettingsModel: ObservableObject { var snapshot: SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, + isTemporarilyPaused: isTemporarilyPaused, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), suggestInIntegratedTerminals: suggestInIntegratedTerminals, selectedEngine: selectedEngine, @@ -680,6 +688,65 @@ final class SuggestionSettingsModel: ObservableObject { store.saveGloballyEnabled(enabled) } + /// Whether autocomplete is currently paused. Expired state is rejected synchronously too, so + /// callers stay correct if the system delayed the timer while the Mac was asleep. + var isTemporarilyPaused: Bool { + pauseState?.isActive() == true + } + + var pauseStatusText: String? { + pauseState?.statusText() + } + + func pauseSuggestions(for duration: SuggestionPauseDuration) { + let newState = duration.pauseState() + guard pauseState != newState else { return } + + pauseState = newState + store.savePauseState(newState) + schedulePauseExpirationIfNeeded() + } + + /// Clears both disable mechanisms used by the menu-bar recovery action. This makes the single + /// "Enable Cotabby" button reliable whether a pause or the older global switch disabled it. + func enableCotabby() { + clearPause() + setGloballyEnabled(true) + } + + func clearPause() { + pauseExpirationTimer?.invalidate() + pauseExpirationTimer = nil + guard pauseState != nil else { return } + + pauseState = nil + store.savePauseState(nil) + } + + private func schedulePauseExpirationIfNeeded() { + pauseExpirationTimer?.invalidate() + pauseExpirationTimer = nil + + guard let expiration = pauseState?.expirationDate else { return } + let interval = expiration.timeIntervalSinceNow + guard interval > 0 else { + clearPause() + return + } + + // The timer only publishes the state transition. The coordinator's existing settings-change + // boundary owns cancellation while pausing and normal reconciliation when the pause ends. + let timer = Timer(timeInterval: interval, repeats: false) { + [weak self] _ in + Task { @MainActor [weak self] in + self?.clearPause() + } + } + timer.fireDate = expiration + RunLoop.main.add(timer, forMode: .common) + pauseExpirationTimer = timer + } + func setSuggestInIntegratedTerminals(_ enabled: Bool) { guard suggestInIntegratedTerminals != enabled else { return @@ -997,7 +1064,11 @@ final class SuggestionSettingsModel: ObservableObject { /// Convenience used by the hotkey callback. Wrapping the flip here keeps the InputMonitor /// closure trivial and gives the menu bar / tests a single entry point. func toggleGloballyEnabled() { - setGloballyEnabled(!isGloballyEnabled) + if isTemporarilyPaused || !isGloballyEnabled { + enableCotabby() + } else { + setGloballyEnabled(false) + } } /// Returns the user-facing name of the shortcut action already bound to `(keyCode, modifiers)`, @@ -1048,7 +1119,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { // via a second CombineLatest to avoid restructuring the existing groupings. let primary = Publishers.CombineLatest4( Publishers.CombineLatest4( - $isGloballyEnabled, + Publishers.CombineLatest($isGloballyEnabled, $pauseState), $disabledAppRules, $selectedEngine, $selectedWordCountPreset @@ -1107,7 +1178,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { ) .map { primaryTuple, granularity, extendedContextTuple, customRangeTuple in let (combinedSettings, presentationToggles, profile, timing) = primaryTuple - let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings + let (globalState, disabledAppRules, engine, wordCountPreset) = combinedSettings + let (globallyEnabled, pauseState) = globalState let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles let (suppressOnTypo, offerCorrections, automaticallyFixTypos) = typoToggles let (userName, customRules, responseLanguages, enabledSpellingDictionaryCodes) = profile @@ -1117,6 +1189,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled) = extendedContextTuple return SuggestionSettingsSnapshot( isGloballyEnabled: globallyEnabled, + isTemporarilyPaused: pauseState?.isActive() == true, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), suggestInIntegratedTerminals: suggestInIntegratedTerminals, selectedEngine: engine, diff --git a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift index f39b86d1..e7c409b1 100644 --- a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift +++ b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift @@ -9,6 +9,7 @@ import Foundation enum SuggestionAvailabilityEvaluator { static func disabledReason( globallyEnabled: Bool = true, + temporarilyPaused: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -20,6 +21,10 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is turned off." } + guard !temporarilyPaused else { + return "Cotabby is temporarily paused." + } + if let bundleIdentifier = focusSnapshot.bundleIdentifier, disabledAppBundleIdentifiers.contains(bundleIdentifier) { return "Cotabby is disabled in \(focusSnapshot.applicationName)." @@ -65,6 +70,7 @@ enum SuggestionAvailabilityEvaluator { static func shouldSchedulePrediction( globallyEnabled: Bool = true, + temporarilyPaused: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -73,6 +79,7 @@ enum SuggestionAvailabilityEvaluator { ) -> Bool { disabledReason( globallyEnabled: globallyEnabled, + temporarilyPaused: temporarilyPaused, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, @@ -95,6 +102,7 @@ enum SuggestionAvailabilityEvaluator { /// same text-only behavior as fast mode instead of disabling autocomplete. static func shouldCaptureVisualContext( globallyEnabled: Bool = true, + temporarilyPaused: Bool = false, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, @@ -113,6 +121,7 @@ enum SuggestionAvailabilityEvaluator { return disabledReason( globallyEnabled: globallyEnabled, + temporarilyPaused: temporarilyPaused, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index f81f398f..0632ab5f 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -70,6 +70,7 @@ struct SuggestionSettingsStore { // MARK: - UserDefaults keys private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled" + private static let pauseStateDefaultsKey = "cotabbySuggestionPauseState" private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules" private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals" private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator" @@ -148,6 +149,7 @@ struct SuggestionSettingsStore { /// would otherwise resurrect. private static let allPreferenceDefaultsKeys: [String] = [ isGloballyEnabledDefaultsKey, + pauseStateDefaultsKey, disabledAppRulesDefaultsKey, suggestInIntegratedTerminalsDefaultsKey, showCaretIndicatorDefaultsKey, @@ -212,6 +214,11 @@ struct SuggestionSettingsStore { /// without a matching migration test; each one protects an existing user's settings. func load(configuration: SuggestionConfiguration) -> SuggestionSettingsData { let resolvedGloballyEnabled = userDefaults.object(forKey: Self.isGloballyEnabledDefaultsKey) as? Bool ?? true + // Expired pauses are discarded during launch so a Mac that was asleep or powered off past + // the deadline never comes back in a stale disabled state. + let persistedPauseState = userDefaults.data(forKey: Self.pauseStateDefaultsKey) + .flatMap { try? JSONDecoder().decode(SuggestionPauseState.self, from: $0) } + let resolvedPauseState = persistedPauseState?.activeState() let resolvedDisabledAppRules = loadDisabledAppRules() let resolvedShowIndicator: Bool = if let modeString = userDefaults.string( forKey: Self.selectedIndicatorModeDefaultsKey @@ -446,6 +453,7 @@ struct SuggestionSettingsStore { let data = SuggestionSettingsData( isGloballyEnabled: resolvedGloballyEnabled, + pauseState: resolvedPauseState, showIndicator: resolvedShowIndicator, showAcceptanceHint: resolvedShowAcceptanceHint, disabledAppRules: resolvedDisabledAppRules, @@ -505,6 +513,7 @@ struct SuggestionSettingsStore { // Unconditional write-back so the resolved (possibly migrated or default-capped) values are // sticky on the next launch. Mirrors the resolution above field-for-field. saveGloballyEnabled(data.isGloballyEnabled) + savePauseState(data.pauseState) saveDisabledAppRules(data.disabledAppRules) saveSuggestInIntegratedTerminals(data.suggestInIntegratedTerminals) saveShowIndicator(data.showIndicator) @@ -593,6 +602,16 @@ struct SuggestionSettingsStore { userDefaults.set(enabled, forKey: Self.isGloballyEnabledDefaultsKey) } + func savePauseState(_ pauseState: SuggestionPauseState?) { + guard let pauseState, + let data = try? JSONEncoder().encode(pauseState) + else { + userDefaults.removeObject(forKey: Self.pauseStateDefaultsKey) + return + } + userDefaults.set(data, forKey: Self.pauseStateDefaultsKey) + } + func saveSuggestInIntegratedTerminals(_ enabled: Bool) { userDefaults.set(enabled, forKey: Self.suggestInIntegratedTerminalsDefaultsKey) } diff --git a/Cotabby/UI/MenuBarStatusLabelView.swift b/Cotabby/UI/MenuBarStatusLabelView.swift index a2e9b415..72207249 100644 --- a/Cotabby/UI/MenuBarStatusLabelView.swift +++ b/Cotabby/UI/MenuBarStatusLabelView.swift @@ -19,6 +19,12 @@ struct MenuBarStatusLabelView: View { .scaledToFit() .frame(height: 16) + if suggestionSettings.isTemporarilyPaused || !suggestionSettings.isGloballyEnabled { + Image(systemName: "pause.fill") + .font(.system(size: 8, weight: .bold)) + .accessibilityLabel("Cotabby paused") + } + if suggestionSettings.isMenuBarWordCountVisible, let label = WordCountFormatter.compactLabel( for: suggestionCoordinator.totalTabAcceptedWordCount diff --git a/Cotabby/UI/MenuBarView.swift b/Cotabby/UI/MenuBarView.swift index e12da621..17974222 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -126,12 +126,42 @@ struct MenuBarView: View { Divider() - // Activation lives in its own band: the global switch plus the per-app override for - // whatever app currently has focus. + // Activation lives in its own band. While active, the menu offers bounded and manual + // pauses. While paused or globally disabled, those choices are replaced by one recovery + // action so the user cannot accidentally stack contradictory disable states. Group { - Toggle("Enable Globally", isOn: globallyEnabledBinding) - .toggleStyle(.switch) - .controlSize(.small) + if suggestionSettings.isTemporarilyPaused || !suggestionSettings.isGloballyEnabled { + Button { + suggestionSettings.enableCotabby() + } label: { + Label("Enable Cotabby", systemImage: "play.fill") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) + + if let pauseStatus = suggestionSettings.pauseStatusText { + Text(pauseStatus) + .font(.caption) + .foregroundStyle(.orange) + } else { + Text("Cotabby is turned off") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Menu { + ForEach(SuggestionPauseDuration.allCases) { duration in + Button(duration.menuLabel) { + suggestionSettings.pauseSuggestions(for: duration) + } + } + } label: { + Label("Pause Cotabby", systemImage: "pause.fill") + .frame(maxWidth: .infinity, alignment: .leading) + } + .menuStyle(.borderlessButton) + .fixedSize(horizontal: false, vertical: true) + } if let application = focusModel.latestExternalApplication, !TerminalAppDetector.isTerminal(bundleIdentifier: application.bundleIdentifier) { @@ -313,13 +343,6 @@ struct MenuBarView: View { // MARK: - Bindings - private var globallyEnabledBinding: Binding { - Binding( - get: { suggestionSettings.isGloballyEnabled }, - set: { suggestionSettings.setGloballyEnabled($0) } - ) - } - private var clipboardContextEnabledBinding: Binding { Binding( get: { suggestionSettings.isClipboardContextEnabled }, diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index fb78df21..e82b926e 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -237,6 +237,7 @@ enum CotabbyTestFixtures { static func settingsSnapshot( isGloballyEnabled: Bool = true, + isTemporarilyPaused: Bool = false, disabledAppBundleIdentifiers: Set = [], suggestInIntegratedTerminals: Bool = false, selectedEngine: SuggestionEngineKind = .llamaOpenSource, @@ -265,6 +266,7 @@ enum CotabbyTestFixtures { ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, + isTemporarilyPaused: isTemporarilyPaused, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, suggestInIntegratedTerminals: suggestInIntegratedTerminals, selectedEngine: selectedEngine, diff --git a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift index d521cc26..24461f17 100644 --- a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift @@ -137,6 +137,17 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertEqual(reason, "Cotabby is turned off.") } + func test_disabledReason_whenTemporarilyPaused_returnsPauseCopy() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + temporarilyPaused: true, + inputMonitoringGranted: true, + focusSnapshot: makeSnapshot(capability: .supported) + ) + + XCTAssertEqual(reason, "Cotabby is temporarily paused.") + } + func test_disabledReason_whenFocusedDomainIsDisabled_returnsSiteReason() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, diff --git a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift index 914831fe..61b02751 100644 --- a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift @@ -71,6 +71,35 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { } } + func test_acceptCurrentSuggestionPassesThroughWhileTemporarilyPaused() { + runOnMainActor { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + let context = FocusedInputContext(snapshot: snapshot, generation: 7) + let interactionState = SuggestionInteractionState() + let session = interactionState.startSession( + fullText: " world", + liveContext: context, + latency: 0.1 + ) + let inserter = StubSuggestionInserter() + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .visible( + text: session.remainingText, + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect), + mode: .inline + ), + inputMonitor: StubSuggestionInputMonitor(), + inserter: inserter, + interactionState: interactionState, + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(isTemporarilyPaused: true) + ) + + XCTAssertFalse(coordinator.acceptCurrentSuggestion()) + XCTAssertTrue(inserter.insertedChunks.isEmpty) + } + } + func test_acceptCurrentSuggestion_withAddSpaceAfterAccept_insertsTrailingSpaceOnNonFinalWord() { // Full coordinator path with the setting ON and a multi-word suggestion: accepting the first // word must insert the word plus the suggestion's own following space (so the toggle fires diff --git a/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift b/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift index bc1d088f..50925370 100644 --- a/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift +++ b/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift @@ -138,4 +138,19 @@ final class SuggestionCoordinatorLifecycleTests: XCTestCase { // The obsolete visual context is still torn down. XCTAssertEqual(rig.visualContext.cancelCalls, [true]) } + + func test_settingsChange_pausingDoesNotRestartThePipeline() { + let rig = retained(makeCoordinatorRig()) + + rig.coordinator.handleSuggestionSettingsChange( + CotabbyTestFixtures.settingsSnapshot( + isTemporarilyPaused: true, + debounceMilliseconds: 1 + ) + ) + + XCTAssertNotEqual(rig.coordinator.state, .debouncing) + XCTAssertTrue(rig.visualContext.startedSessions.isEmpty) + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } } diff --git a/CotabbyTests/SuggestionPauseModelsTests.swift b/CotabbyTests/SuggestionPauseModelsTests.swift new file mode 100644 index 00000000..2b420170 --- /dev/null +++ b/CotabbyTests/SuggestionPauseModelsTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import Cotabby + +/// Locks the deterministic time math behind menu-bar pause choices. Keeping these tests pure makes +/// DST/calendar behavior reviewable without waiting for a real settings-model timer to fire. +final class SuggestionPauseModelsTests: XCTestCase { + func test_minuteAndHourDurationsUseExpectedIntervals() { + let now = Date(timeIntervalSince1970: 1_000_000) + + XCTAssertEqual( + SuggestionPauseDuration.fifteenMinutes.pauseState(from: now), + .until(now.addingTimeInterval(15 * 60)) + ) + XCTAssertEqual( + SuggestionPauseDuration.thirtyMinutes.pauseState(from: now), + .until(now.addingTimeInterval(30 * 60)) + ) + XCTAssertEqual( + SuggestionPauseDuration.oneHour.pauseState(from: now), + .until(now.addingTimeInterval(60 * 60)) + ) + } + + func test_untilTomorrowUsesNextLocalCalendarMidnight() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "America/Toronto")) + let now = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 6, day: 28, hour: 22, minute: 30)) + ) + let expected = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 6, day: 29)) + ) + + XCTAssertEqual( + SuggestionPauseDuration.untilTomorrow.pauseState(from: now, calendar: calendar), + .until(expected) + ) + } + + func test_timedPauseBecomesInactiveAtExpiration() { + let expiration = Date(timeIntervalSince1970: 2_000) + let state = SuggestionPauseState.until(expiration) + + XCTAssertTrue(state.isActive(at: expiration.addingTimeInterval(-0.001))) + XCTAssertFalse(state.isActive(at: expiration)) + XCTAssertNil(state.activeState(at: expiration.addingTimeInterval(1))) + XCTAssertTrue(SuggestionPauseState.indefinitely.isActive(at: expiration)) + } +} diff --git a/CotabbyTests/SuggestionSettingsModelTests.swift b/CotabbyTests/SuggestionSettingsModelTests.swift index 752b2bf2..3a69adc0 100644 --- a/CotabbyTests/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/SuggestionSettingsModelTests.swift @@ -41,6 +41,7 @@ final class SuggestionSettingsModelTests: XCTestCase { // invisible here, but a broken save or a load/save key mismatch fails the reload below. for _ in 0..<2 { model.setGloballyEnabled(false) + model.pauseSuggestions(for: .indefinitely) model.selectEngine(.appleIntelligence) model.selectWordCountPreset(.twelveToTwenty) model.setUsingCustomWordCountRange(true) @@ -80,6 +81,7 @@ final class SuggestionSettingsModelTests: XCTestCase { let reloaded = makeModel() XCTAssertFalse(reloaded.isGloballyEnabled) + XCTAssertTrue(reloaded.isTemporarilyPaused) XCTAssertEqual(reloaded.selectedEngine, .appleIntelligence) XCTAssertEqual(reloaded.selectedWordCountPreset, .twelveToTwenty) XCTAssertTrue(reloaded.isUsingCustomWordCountRange) @@ -562,6 +564,7 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(snapshots.count, 1, "CombineLatest must emit the current state on subscribe") XCTAssertEqual(snapshots.last?.isGloballyEnabled, model.isGloballyEnabled) + XCTAssertEqual(snapshots.last?.isTemporarilyPaused, false) let countBeforeNoOp = snapshots.count model.setGloballyEnabled(model.isGloballyEnabled) @@ -571,6 +574,13 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(snapshots.count, countBeforeNoOp + 1) XCTAssertEqual(snapshots.last?.isGloballyEnabled, model.isGloballyEnabled) + model.pauseSuggestions(for: .indefinitely) + XCTAssertEqual(snapshots.last?.isTemporarilyPaused, true) + + model.enableCotabby() + XCTAssertEqual(snapshots.last?.isTemporarilyPaused, false) + XCTAssertTrue(model.isGloballyEnabled) + // A custom-range edit flows into the snapshot pre-clamped. model.setUsingCustomWordCountRange(true) model.setCustomWordCountRange(low: 2, high: 200) diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index 7b09bba4..f7f05faa 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -185,6 +185,25 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertEqual(data.fadeInDurationSeconds, 0.25, accuracy: 0.0001) } + func test_saveThenLoad_roundTripsIndefinitePause() async { + let defaults = makeIsolatedDefaults() + let store = SuggestionSettingsStore(userDefaults: defaults) + + store.savePauseState(.indefinitely) + + XCTAssertEqual(store.load(configuration: .standard).pauseState, .indefinitely) + } + + func test_loadDropsExpiredTimedPause() async { + let defaults = makeIsolatedDefaults() + let store = SuggestionSettingsStore(userDefaults: defaults) + + store.savePauseState(.until(Date().addingTimeInterval(-1))) + + XCTAssertNil(store.load(configuration: .standard).pauseState) + XCTAssertNil(defaults.object(forKey: "cotabbySuggestionPauseState")) + } + func test_load_fadeInSuggestionsDefaultsOn() async { let defaults = makeIsolatedDefaults() @@ -499,6 +518,7 @@ final class SuggestionSettingsStoreTests: XCTestCase { // methods the facade uses), plus the legacy single-language key. If `resetToDefaults` misses // any key, the reloaded data stays != pristine and the Equatable check below fails loudly. store.saveGloballyEnabled(false) + store.savePauseState(.indefinitely) store.saveDisabledAppRules( [DisabledApplicationRule(bundleIdentifier: "com.example.app", displayName: "Example")] ) From 90b66ad0a89e9f9cbe8320b477635e961fff345f Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:47:23 -0400 Subject: [PATCH 2/5] fix: resolve SwiftLint violations --- .../SuggestionCoordinator+Acceptance.swift | 11 +++++++++++ Cotabby/Models/SuggestionSettingsModel.swift | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 83e627da..d192448b 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -30,6 +30,17 @@ extension SuggestionCoordinator { return passTabThrough(reason: disabledReason) } + return acceptEnabledSuggestion(fullText: fullText, keyName: keyName, snapshot: snapshot) + } + + /// Continues acceptance after global, per-app, and temporary-pause gating succeeds. Keeping the + /// eligibility boundary separate prevents the state-machine-heavy acceptance path from gaining + /// another decision branch each time Cotabby adds a new top-level disable mechanism. + private func acceptEnabledSuggestion( + fullText: Bool, + keyName: String, + snapshot: FocusSnapshot + ) -> Bool { guard permissionManager.inputMonitoringGranted else { return passTabThrough( reason: "Input Monitoring permission is required before Cotabby can accept suggestions." diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index a25f6c4d..d0c75390 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -736,8 +736,7 @@ final class SuggestionSettingsModel: ObservableObject { // The timer only publishes the state transition. The coordinator's existing settings-change // boundary owns cancellation while pausing and normal reconciliation when the pause ends. - let timer = Timer(timeInterval: interval, repeats: false) { - [weak self] _ in + let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.clearPause() } From 069d21bdc75d4fe78c3c9ae122e947a48c436fe0 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:02:49 -0400 Subject: [PATCH 3/5] fix: distinguish disabled accessibility status --- Cotabby/UI/MenuBarStatusLabelView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Cotabby/UI/MenuBarStatusLabelView.swift b/Cotabby/UI/MenuBarStatusLabelView.swift index 72207249..250b352a 100644 --- a/Cotabby/UI/MenuBarStatusLabelView.swift +++ b/Cotabby/UI/MenuBarStatusLabelView.swift @@ -22,7 +22,7 @@ struct MenuBarStatusLabelView: View { if suggestionSettings.isTemporarilyPaused || !suggestionSettings.isGloballyEnabled { Image(systemName: "pause.fill") .font(.system(size: 8, weight: .bold)) - .accessibilityLabel("Cotabby paused") + .accessibilityLabel(inactiveAccessibilityLabel) } if suggestionSettings.isMenuBarWordCountVisible, @@ -34,4 +34,11 @@ struct MenuBarStatusLabelView: View { } } } + + /// VoiceOver needs the persistent global disable state distinguished from a temporary pause. + /// Global disable takes precedence when both states are present because it remains in effect + /// after the temporary pause is cleared. + private var inactiveAccessibilityLabel: String { + suggestionSettings.isGloballyEnabled ? "Cotabby paused" : "Cotabby disabled" + } } From 04855f8bd34e57a7ca855cffe2c95845f8c7a44c Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:05:09 -0400 Subject: [PATCH 4/5] fix: simplify pause expiration timer --- Cotabby/Models/SuggestionSettingsModel.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index d0c75390..d4f35cec 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -736,12 +736,11 @@ final class SuggestionSettingsModel: ObservableObject { // The timer only publishes the state transition. The coordinator's existing settings-change // boundary owns cancellation while pausing and normal reconciliation when the pause ends. - let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in + let timer = Timer(fire: expiration, interval: 0, repeats: false) { [weak self] _ in Task { @MainActor [weak self] in self?.clearPause() } } - timer.fireDate = expiration RunLoop.main.add(timer, forMode: .common) pauseExpirationTimer = timer } From 93aeccc8cfbd415eea028439cdff95883b67f920 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:12:17 -0400 Subject: [PATCH 5/5] fix: preserve pause state on encode failure --- Cotabby/Support/SuggestionSettingsStore.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 0632ab5f..3794df7c 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -603,13 +603,14 @@ struct SuggestionSettingsStore { } func savePauseState(_ pauseState: SuggestionPauseState?) { - guard let pauseState, - let data = try? JSONEncoder().encode(pauseState) - else { + guard let pauseState else { userDefaults.removeObject(forKey: Self.pauseStateDefaultsKey) return } - userDefaults.set(data, forKey: Self.pauseStateDefaultsKey) + + if let data = try? JSONEncoder().encode(pauseState) { + userDefaults.set(data, forKey: Self.pauseStateDefaultsKey) + } } func saveSuggestInIntegratedTerminals(_ enabled: Bool) {