From a8dfda625bfce885cb483c1c30e9a099426349ef Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 12:33:38 +0200 Subject: [PATCH 1/3] Add opt-in typing history: learn from what the user writes, import Cotypist Cotabby had no memory of past writing; every suggestion saw only the current field. This adds a local, encrypted typing history that shapes suggestions on the on-device engines. - TypingHistoryStore records the text of fields where Cotabby is active (never secure fields, disabled or excluded apps, or while paused), scrubs secret-like tokens, and seals the archive with AES-GCM under a ThisDeviceOnly Keychain key (TypingHistoryVault). Delete All removes the file and the key. - History is used two ways: TypingHistoryIndex adds two short passages of similar past writing to the prompt (refreshed per 8-word block so the llama KV prefix stays reusable), and TypingHistoryPhraseEngine answers from TypingHistoryPhrasePredictor when history is confident how a phrase ends, without calling the model. - Only text before the caret is learned from; the rest of a field is often a quoted thread someone else wrote. - A Cotypist user_inputs.json export can be imported, collapsing its repeated snapshots of the same field. - On-device only: the provider returns nothing for the endpoint engine, the request factory drops examples for it, and the router refuses any request that still carries them (power-source switching can change the live engine after a request is built). - Settings -> Context gains a Typing History section; both switches are off by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017xvrRyxDAooaBCvNfZiAA7 --- ARCHITECTURE.md | 13 + Cotabby.xcodeproj/project.pbxproj | 118 ++++++ .../Coordinators/SettingsCoordinator.swift | 6 +- .../SuggestionCoordinator+Continuation.swift | 3 +- .../SuggestionCoordinator+Input.swift | 3 +- .../SuggestionCoordinator+Prediction.swift | 9 +- .../Suggestion/SuggestionCoordinator.swift | 13 + Cotabby/App/Core/AppDelegate.swift | 2 + Cotabby/App/Core/CotabbyAppEnvironment.swift | 37 +- .../Models/History/TypingHistoryModels.swift | 72 ++++ .../Request/SuggestionRequest.swift | 6 + .../SuggestionSubsystemContracts.swift | 13 + .../Services/History/TypingHistoryStore.swift | 369 ++++++++++++++++++ .../Services/History/TypingHistoryVault.swift | 131 +++++++ .../Runtime/SuggestionEngineRouter.swift | 10 + .../Runtime/TypingHistoryPhraseEngine.swift | 64 +++ .../History/CotypistExportImporter.swift | 105 +++++ .../Support/History/TypingHistoryIndex.swift | 202 ++++++++++ .../TypingHistoryPhrasePredictor.swift | 233 +++++++++++ .../History/TypingHistoryScrubber.swift | 90 +++++ .../BaseCompletionPromptRenderer.swift | 21 + .../FoundationModelPromptRenderer.swift | 10 + .../Request/SuggestionRequestFactory.swift | 9 +- .../UI/Settings/Panes/ContextPaneView.swift | 3 + .../Panes/TypingHistorySectionView.swift | 118 ++++++ .../UI/Settings/SettingsContainerView.swift | 4 +- Cotabby/UI/Settings/SettingsIndex.swift | 9 +- .../History/TypingHistoryStoreTests.swift | 244 ++++++++++++ .../Runtime/SuggestionEngineRouterTests.swift | 12 + .../TypingHistoryPhraseEngineTests.swift | 116 ++++++ .../History/CotypistExportImporterTests.swift | 50 +++ .../History/TypingHistoryIndexTests.swift | 75 ++++ .../TypingHistoryPhrasePredictorTests.swift | 121 ++++++ .../History/TypingHistoryScrubberTests.swift | 38 ++ .../TestSupport/CotabbyTestFixtures.swift | 2 + SOURCE_LAYOUT.md | 3 + 36 files changed, 2321 insertions(+), 13 deletions(-) create mode 100644 Cotabby/Models/History/TypingHistoryModels.swift create mode 100644 Cotabby/Services/History/TypingHistoryStore.swift create mode 100644 Cotabby/Services/History/TypingHistoryVault.swift create mode 100644 Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift create mode 100644 Cotabby/Support/History/CotypistExportImporter.swift create mode 100644 Cotabby/Support/History/TypingHistoryIndex.swift create mode 100644 Cotabby/Support/History/TypingHistoryPhrasePredictor.swift create mode 100644 Cotabby/Support/History/TypingHistoryScrubber.swift create mode 100644 Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift create mode 100644 CotabbyTests/Services/History/TypingHistoryStoreTests.swift create mode 100644 CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift create mode 100644 CotabbyTests/Support/History/CotypistExportImporterTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryIndexTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift create mode 100644 CotabbyTests/Support/History/TypingHistoryScrubberTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f21a3837..ece20a82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -396,6 +396,19 @@ before marking a secure field blocked; that lower-level acquisition remains sepa Endpoint credentials are stored in Keychain. A remote endpoint receives its bounded, legacy-scope request; its privacy scope must remain visible in settings and documentation. +Typing history (Settings → Context → Typing History) is the one store of the user's writing that +outlives its field. Both of its switches are off by default. When recording is on, +`TypingHistoryStore` keeps the text of fields where Cotabby is active (never secure fields, disabled +or excluded apps, or while paused), scrubs secret-like tokens (`TypingHistoryScrubber`), and seals +the archive with AES-GCM under a Keychain key that never syncs (`TypingHistoryVault`). Delete All +removes the file and the key. A Cotypist `user_inputs.json` export can be imported. Only text that +was before the caret is learned from, because the rest of a field is often a quoted thread. History +shapes suggestions in two ways: `TypingHistoryIndex` adds two short passages of similar past writing +to the prompt, and `TypingHistoryPhraseEngine` answers from `TypingHistoryPhrasePredictor` when +history confidently knows how a phrase ends. Both are on-device only: the provider returns nothing +for the endpoint, the request factory drops examples for it, and the router refuses to send any +request that still carries them. + ## Presentation and Sibling Features [SuggestionOverlayPresenter.swift](Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift) diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..c17ed32b 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 109F9BD7E1AE3E7FB0C712AA /* GhostTextLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB202C5AD6079174A14330C /* GhostTextLayoutTests.swift */; }; 1104ABD01283774EDF249EF0 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; 111905666B5F1D0B65B87D44 /* CapturedInputEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353191D1D8A1C655E1B5F562 /* CapturedInputEventTests.swift */; }; + 113710208D17C1998ED307E1 /* TypingHistoryScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */; }; 113D4A18C8782C578463A6C4 /* GhostTextLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C0792BF971B702DE0A095E /* GhostTextLayout.swift */; }; 11C548C2B50D9B27BD1B7293 /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E9AF341F8F7043D5344B20 /* SymSpell.swift */; }; 11D3A5C8DA17BCDB79969FC4 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */; }; @@ -151,6 +152,7 @@ 226C258F185E2E71518F4ABB /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F86D2716CBE36970BB462E8 /* OnboardingTemplateRecommender.swift */; }; 231905D5C463FA994CACFA77 /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; 23676A1A7099AC04091FD6A3 /* EmojiPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */; }; + 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */; }; 2420363A5F652F51693A018B /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; }; 24A2D498222EB81B64EC36C9 /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADE6EEA8248DA6700E11844 /* MenuBarPopoverDismisser.swift */; }; 24A393E033AB22A1C9951770 /* DecodeStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D7F428477976398CFC5CCF /* DecodeStopPolicyTests.swift */; }; @@ -178,9 +180,11 @@ 2AF12199E0FD9B802BF35A10 /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D41CC179EC04093971A0D0 /* SpellingDictionaryCatalog.swift */; }; 2B355C61C20866199F414EB9 /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */; }; 2B4049F81730E41A1274E57F /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */; }; + 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */; }; 2C4480F23602E7F7FAB50A5D /* CompletionSeamGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */; }; 2C44C5142ED914BF99A70EC6 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; 2CA5740E5D8A1167043D6045 /* ArithmeticEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5897D28F4DBEAF4792FB4D24 /* ArithmeticEvaluator.swift */; }; + 2CDE0BF9AF320B269C82E937 /* TypingHistoryPhraseEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */; }; 2CDF880348D439708BD4F792 /* ru.txt in Resources */ = {isa = PBXBuildFile; fileRef = D8C2663427BC5219EA415D00 /* ru.txt */; }; 2CF9087AE6FDB2A182BA9C85 /* MirrorOverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3732CD6886AEAF3C01F543E /* MirrorOverlayLayout.swift */; }; 2D33BFF41BDE6CE7464885B8 /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 266C8BF167118855C56FC7F4 /* BrowserAppDetector.swift */; }; @@ -260,6 +264,7 @@ 3EAFEEC83B25B023C606A726 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B8727111208CB506C25C4F /* ContextBuffer.swift */; }; 3F8E5F610B3AD803DDA007CC /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF96999230079A768BEFD0FC /* KeycapView.swift */; }; 3FA7429EE4BDE4CE0D8E7DA0 /* SuggestionQualityMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5CD24D2FF35FD36B8D78DE /* SuggestionQualityMetricsStoreTests.swift */; }; + 4002A961699B7565A9DB5BF7 /* TypingHistoryPhrasePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */; }; 401409603DE521880F75C779 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; }; 403C8D745B0F6133382D78F9 /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */; }; 407D80367A0243FBCC9865D6 /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD851386E85D3ED0A1F17E47 /* TrailingDuplicationFilter.swift */; }; @@ -331,13 +336,16 @@ 543B45F8FF4A6AFCB9286521 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 5461DD0133FE632B2EB4936A /* BrowserAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589029534F91D2BE26A65263 /* BrowserAppDetectorTests.swift */; }; 54F87EB87E46B987E3679514 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17794AE5A5D5A93A7919E6BA /* FoundationModelSuggestionEngine.swift */; }; + 5509B327A1C9E67467333B06 /* CotypistExportImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */; }; 55160DA471782307483E359F /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CADFBB03C10309B9048C8E9 /* SuggestionSessionReconciler.swift */; }; 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C25CEF5C997F920C7BF0F /* FieldStyleCache.swift */; }; 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7014E6E969FD4579028BAC4 /* TypingCadence.swift */; }; 556197D64680237CBCCCDD9F /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53ED6F2F3354FB3E6065804 /* WelcomeTemplateStepView.swift */; }; + 5619C761998CCBB3FD50CBDB /* TypingHistoryScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */; }; 56471188E5F78EE0785D7BF7 /* CotabbyInference-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2472C76E96009300EBF038D4 /* CotabbyInference-MIT.txt */; }; 564BA8F230F45E378D26B36C /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; + 56987393D95962CD49B4A4E8 /* TypingHistoryPhraseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */; }; 56D2FDE72FE21C35E8BDD06C /* ActivationIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */; }; 56D7E792922B83393A578E5A /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 705E9119B1661BF464496D39 /* CompletionRenderModePolicy.swift */; }; 5732858F8FF26E50AE4BFF8C /* DebugForcedSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8D50115291212D03414CBE /* DebugForcedSuggestionEngine.swift */; }; @@ -384,6 +392,7 @@ 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; 6147AB8372652B7730749011 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */; }; 61932DE224F103A22C8E4EF7 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; + 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */; }; 62B45766E8A2827E31AFD356 /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B49898B64D4DB50736CBC46 /* ScreenFrameReader.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 632C9A837EC1949B098A0BD9 /* SettingsNavigationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CAE5772E4C748960219DA0 /* SettingsNavigationModelTests.swift */; }; @@ -423,6 +432,7 @@ 6B9B1630E1F30EAE429B5B60 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 6BED0111C262E06BFF751BF4 /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */; }; 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F324C645A4E53FB6E90061A /* DownloadOutcomeClassifier.swift */; }; + 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */; }; 6CB0A928230ACD262B02CFDF /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */; }; 6CB435B203CBDD1F63B93332 /* PerAppShortcutOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */; }; 6CCFAA1D86C4E4DC7E3DB8A3 /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5652FFF8E718A17D98F87CD0 /* SuggestionSubsystemContracts.swift */; }; @@ -430,7 +440,9 @@ 6DB11ABBCB996178084FF5A2 /* WelcomeKeybindStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2576B762374F4D3CD8347AD4 /* WelcomeKeybindStepView.swift */; }; 6DFF14EEC5838BC309D03B09 /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */; }; 6E666BEE80ECE65CF71C7F56 /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */; }; + 6E978AD7E340B2795F120AC0 /* CotypistExportImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */; }; 6EC1C0311B57074487F9658F /* GhostFontSizeLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61CA8FEFB45FB1CCA453A /* GhostFontSizeLimits.swift */; }; + 6F18827DF0D0CC9ADCCA83C2 /* TypingHistoryPhraseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */; }; 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 6F3B43828817C2C303E8D16E /* InlineCommandCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */; }; 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */; }; @@ -453,6 +465,7 @@ 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 73A1C073056E07657992AE5A /* CaretTokenPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBE00782EE6813AF3224CD5 /* CaretTokenPosition.swift */; }; 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */; }; + 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74C07F4568C4FAE1C344E789 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD4DE5D9D8C9BB66B78633 /* TextDirectionDetectorTests.swift */; }; @@ -543,7 +556,9 @@ 8D380BEC82C2969F3ED2161A /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F63A5EF992329FB9F6C4C26 /* HardwareCapabilityProbe.swift */; }; 8D83B47447409074AAD1A423 /* MidWordContinuationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F56B79BF030507CC7FAE4D /* MidWordContinuationPolicy.swift */; }; 8DA36F1521B6A59D8C20AC59 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 5A60D1467BBFECB3DFEB39C2 /* Logging */; }; + 8DCEEB9FCB3F7C90B7B4917F /* TypingHistoryIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */; }; 8E209BC819EBBCA0E7F10775 /* FoundationModelAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */; }; + 8E236047C490CD266E603823 /* TypingHistoryPhrasePredictorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */; }; 8E460A307B1ED0DA783CB247 /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD5C8648740E7050CCA657F /* FocusPollBackoff.swift */; }; 8E4DFB1338AE3BDA8FF16B98 /* SuggestionContinuationPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */; }; 8E5D6584BA4168C7E381EF91 /* SuggestionLengthPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA2EA95C425C3EAF246C66D /* SuggestionLengthPolicy.swift */; }; @@ -659,8 +674,10 @@ A9B01E476E483F2A712F1B90 /* HostFontRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F328B43743D803A16045110 /* HostFontRegistry.swift */; }; AA00D42DFD1EE094E01A7EEA /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; }; AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; }; + AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */; }; AAC519AC668EF430F68B06CA /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D68B5ABA427D3E87000E78 /* InlineCommandCoordinator.swift */; }; AAF3A7A25A05E52DC0BED6FB /* EmojiPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC8599C00D6C2CA5FB7A7E7 /* EmojiPickerController.swift */; }; + AB15E751142E1FDB2F8A389F /* TypingHistoryPhrasePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */; }; AB1B25E213AE0A79C6993239 /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */; }; AB5D37BA744546F14C5566E8 /* AppearancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */; }; AB8F186A7CB13779DAF729A0 /* MacroEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D6D153C80A5BDE4905B81E /* MacroEngineTests.swift */; }; @@ -700,6 +717,7 @@ B582B229486BFA041A4641BA /* DateMacroEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34A14D9D852BB9C6829DDC7 /* DateMacroEvaluatorTests.swift */; }; B5C255E58C128C4042FA7F66 /* PhrasePredictionScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E542E57459488F3D39A9053 /* PhrasePredictionScoringTests.swift */; }; B65B49F24F59154A7611FD22 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; + B695D8396E7FE07438DF9C9F /* TypingHistoryIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */; }; B6BA7DF77DCA55F9EF090AEE /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; B6C2570DB8D17C8626D50F76 /* SuggestionCoordinatorContinuityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5830E18C10728B7AF1DB2CCF /* SuggestionCoordinatorContinuityTests.swift */; }; B6F12EFB0AA3CC6BA8773476 /* CaretLinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5E9B2A196282738F8D4BAD /* CaretLinePosition.swift */; }; @@ -781,6 +799,7 @@ C8B5E45A6F45B307C067CF61 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */; }; C95343A5A8496627F44233F0 /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */; }; CA959F082DB2C6E95C5743C8 /* GhostBaselinePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25782BF21045F20B9721DB90 /* GhostBaselinePolicyTests.swift */; }; + CAE609E1F40C3F1B7E4AAA43 /* TypingHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */; }; CB2D43A2B7568173A8E82CE4 /* GhostWrapBandPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AD68E34CF598F2C83D7C73 /* GhostWrapBandPolicyTests.swift */; }; CC54CAD96A348364989132D3 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F95814016D80E0A23974F0 /* ActiveSuggestionSession.swift */; }; CC615916E478E98D403F5714 /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */; }; @@ -829,6 +848,7 @@ D58B73ED080E5D253A838FAC /* SpellingDictionaryResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */; }; D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */; }; D59584461B4A854572C331D2 /* CompletionRenderModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816106CA251AFDE69A804762 /* CompletionRenderModeTests.swift */; }; + D5BDC97A559072C580DAE0FB /* TypingHistoryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */; }; D5C6524646C545CFA08D00C9 /* PixelCaretLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A65BCABF4B23690AE3DBE93 /* PixelCaretLocator.swift */; }; D5F36CA1A9400432CB1FDAFC /* ArithmeticEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5897D28F4DBEAF4792FB4D24 /* ArithmeticEvaluator.swift */; }; D66623DB320CB93A7EBA9B86 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B8D2232F271197468CBC11 /* FocusCapabilityResolver.swift */; }; @@ -851,7 +871,9 @@ DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA02F079AD5EB20CDB215 /* TypefaceMatcher.swift */; }; DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */; }; DA8D967DFA3AFDE469D1FAE7 /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630D1F95FD08BC30E624672 /* DebouncePolicy.swift */; }; + DAD9C50637407AFA2620DBC9 /* TypingHistoryIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */; }; DC0394C83D334B92A512A775 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; + DC3B4CF0634704EF2ADA7C94 /* CotypistExportImporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */; }; DC6C087A7C0E973967BB38D5 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1AE4120FA68524700C9324 /* BaseCompletionPromptRenderer.swift */; }; DC6FEE1602B6891CA4D0CDCA /* VisualContextStartCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70F68E9A9255F607C4439ED5 /* VisualContextStartCoalescerTests.swift */; }; DC9EBC526DBF265C00853AE4 /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB53B865B0FA4378DEF2054 /* LanguageTagsEditor.swift */; }; @@ -883,6 +905,7 @@ E5CF3CFC00B6607AE8D62FED /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE72A73D906285AE10F623B /* EmojiRecents.swift */; }; E60DE8299D729AB113647904 /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */; }; E6650273045E6F1976AF23CA /* EngineAndModelPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C35A0A68DA5D2B59460A67E /* EngineAndModelPaneView.swift */; }; + E68459DC91D2F70D9D3D1A40 /* TypingHistoryScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */; }; E6A062DEDF942C57A77A1AB7 /* TokenHealingPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA9ADF6C0BD9D16016AA9B /* TokenHealingPlan.swift */; }; E6A96157E8689F4762D83AC8 /* Llama.cpp-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 617B396FAD84E6F4A270B5EA /* Llama.cpp-MIT.txt */; }; E6C99AB3E1A56E71F3A518FB /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050DC9D858A124FD1CFA4D99 /* ModelDownloadManager.swift */; }; @@ -966,6 +989,7 @@ FBC44557CFBA826392930ABD /* SystemResourceSamplerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FA4E40E8DF352247E1374B /* SystemResourceSamplerTests.swift */; }; FC1757BF6BF6636C5581A373 /* FocusedInputContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB06531A54FDCDBAAD14E8B /* FocusedInputContext.swift */; }; FC4706CDB470537FE069FF22 /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */; }; + FC61422DC8E8EBD10A8F439B /* TypingHistoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */; }; FCB060F562093AACA90CAA21 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */; }; FCD81796FE4DC55778D57686 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122298AE151ECEEC175878BF /* ConfidenceSuppressionPolicy.swift */; }; @@ -1022,6 +1046,7 @@ 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; + 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhraseEngine.swift; sourceTree = ""; }; 0F328B43743D803A16045110 /* HostFontRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostFontRegistry.swift; sourceTree = ""; }; 10107090891BE11E2B34BC9E /* TypefaceMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypefaceMatcherTests.swift; sourceTree = ""; }; 110F737140F015E1A18E5A58 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = ""; }; @@ -1062,6 +1087,7 @@ 1DE72A73D906285AE10F623B /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = ""; }; 1DF8AA184C5F9CFFDF97A070 /* RuntimeBootstrapModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModel.swift; sourceTree = ""; }; 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicy.swift; sourceTree = ""; }; + 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryStore.swift; sourceTree = ""; }; 1EE61CD2E8D2399E23D48818 /* CompositionInputModeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifierTests.swift; sourceTree = ""; }; 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetector.swift; sourceTree = ""; }; 1EE99DFA6D7D4B925AFAD1B4 /* OpenAICompatibleAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClient.swift; sourceTree = ""; }; @@ -1129,6 +1155,7 @@ 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngineTests.swift; sourceTree = ""; }; 384D85534F3C2F40E2FD8781 /* CompletionContentPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionContentPolicy.swift; sourceTree = ""; }; 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouter.swift; sourceTree = ""; }; + 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryScrubber.swift; sourceTree = ""; }; 38D10022BEFABAD14D16EBF2 /* SuggestionOverlayStabilityGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGateTests.swift; sourceTree = ""; }; 38FCE0E0C38A776383B11809 /* TypoGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGate.swift; sourceTree = ""; }; 39AD68E34CF598F2C83D7C73 /* GhostWrapBandPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostWrapBandPolicyTests.swift; sourceTree = ""; }; @@ -1181,6 +1208,7 @@ 4F86D2716CBE36970BB462E8 /* OnboardingTemplateRecommender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommender.swift; sourceTree = ""; }; 4FFD3AFACAAC963DAF59D153 /* OnboardingTemplateRecommenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommenderTests.swift; sourceTree = ""; }; 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionQualityMetricsStore.swift; sourceTree = ""; }; + 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryIndexTests.swift; sourceTree = ""; }; 50A879A0AD69BCA2F60D2735 /* CaretAdvanceSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretAdvanceSampler.swift; sourceTree = ""; }; 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBuffer.swift; sourceTree = ""; }; 50CDB11D96F343194B390321 /* MacroTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroTriggerStateMachine.swift; sourceTree = ""; }; @@ -1255,6 +1283,7 @@ 6C74D10CCC6CB008695F5C0A /* SentenceBoundaryClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryClassifierTests.swift; sourceTree = ""; }; 6C896CEDC632FB738D3A42BF /* SignOffCue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCue.swift; sourceTree = ""; }; 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; + 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryStoreTests.swift; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlan.swift; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; @@ -1294,6 +1323,7 @@ 77DB52780A112A686C06D7E3 /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; 7814C85DA992C8124474FD81 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; 78721FCF04E4D6F0C7C5DDCF /* llama-recall-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "llama-recall-cases.json"; sourceTree = ""; }; + 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotypistExportImporter.swift; sourceTree = ""; }; 790082EFCDEF94454A3B805F /* SuggestionContinuationPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlanTests.swift; sourceTree = ""; }; 79AA39C97E8A959E9A5B6F04 /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluator.swift; sourceTree = ""; }; @@ -1302,11 +1332,13 @@ 7B9C30F20BE1D9C3B0AF8A9E /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7CC0C7FE7FB134C225EA7B71 /* CompletionSeamGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuardTests.swift; sourceTree = ""; }; + 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryModels.swift; sourceTree = ""; }; 7D2E806C1915514651343055 /* HuggingFaceModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelsTests.swift; sourceTree = ""; }; 7D2F4ED8C7493EF4B967BFF9 /* Aria2Provisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2Provisioner.swift; sourceTree = ""; }; 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCueTests.swift; sourceTree = ""; }; 7F7E66CFA5AB733CE1F793A8 /* MarkerSelectionSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizerTests.swift; sourceTree = ""; }; + 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhrasePredictorTests.swift; sourceTree = ""; }; 80588CA72AB588807BE1C499 /* TokenHealingBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBufferTests.swift; sourceTree = ""; }; 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTriggerStateMachine.swift; sourceTree = ""; }; 816106CA251AFDE69A804762 /* CompletionRenderModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModeTests.swift; sourceTree = ""; }; @@ -1326,6 +1358,7 @@ 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = ""; }; 872C8DDC2E86A1C4C4BBD99F /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = ""; }; 8736D4321E8BF847D1F7338E /* EmojiPickerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModels.swift; sourceTree = ""; }; + 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryScrubberTests.swift; sourceTree = ""; }; 87C6335EED41343595F17A08 /* PopupChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupChrome.swift; sourceTree = ""; }; 8922EEFC979DE2C2E480FB0D /* ModelDownloadSessionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloadSessionDelegateTests.swift; sourceTree = ""; }; 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneView.swift; sourceTree = ""; }; @@ -1377,6 +1410,7 @@ 9F63A5EF992329FB9F6C4C26 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = ""; }; 9F8418BB40BEE4BCAFB4516B /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 9FA2CFC322EE56151646E745 /* HostBundledFontRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBundledFontRegistryTests.swift; sourceTree = ""; }; + 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistorySectionView.swift; sourceTree = ""; }; A01BF1613C95C767A2E93D8F /* ContextPaneViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneViewTests.swift; sourceTree = ""; }; A04DFC5BD7821D73DA21D7DB /* TagChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagChip.swift; sourceTree = ""; }; A061B0B064B469180B2F1D63 /* TypingExperienceEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingExperienceEvalTests.swift; sourceTree = ""; }; @@ -1460,6 +1494,7 @@ BE5E9B2A196282738F8D4BAD /* CaretLinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePosition.swift; sourceTree = ""; }; BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCatalog.swift; sourceTree = ""; }; BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAnchorCacheTests.swift; sourceTree = ""; }; + BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotypistExportImporterTests.swift; sourceTree = ""; }; BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GPL-3.0.txt"; sourceTree = ""; }; BFB53B865B0FA4378DEF2054 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; C01F11F7F29EFC3A27A1EE5D /* FocusSnapshotResolverSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverSelectionTests.swift; sourceTree = ""; }; @@ -1533,6 +1568,7 @@ D9CFD041A13DACE5612DC991 /* HostMarkedTextPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostMarkedTextPolicy.swift; sourceTree = ""; }; DA02598F555696782E180DA5 /* ShortcutConflictTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConflictTests.swift; sourceTree = ""; }; DA0447A27B293394D9379C48 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = ""; }; + DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryIndex.swift; sourceTree = ""; }; DAC4D7129517D678D95D0BC2 /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; DADE6EEA8248DA6700E11844 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = ""; }; DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContainerView.swift; sourceTree = ""; }; @@ -1563,6 +1599,7 @@ E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetector.swift; sourceTree = ""; }; E80071E4A5D844CEF5B7C487 /* RequestIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestIDTests.swift; sourceTree = ""; }; E83C6E965A3978D0EBFB1EA2 /* ShortcutResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutResolver.swift; sourceTree = ""; }; + E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhraseEngineTests.swift; sourceTree = ""; }; E94A6036F4B703A2FF7F68EF /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; EA8311FAC345FE431FA89855 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; EAA290A7B1BF37F3D125DC8F /* SuggestionCoordinatorPredictionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorPredictionTests.swift; sourceTree = ""; }; @@ -1578,6 +1615,7 @@ ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; ECD5C8648740E7050CCA657F /* FocusPollBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoff.swift; sourceTree = ""; }; ECF1C918E49607E99AE50C80 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = ""; }; + ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryVault.swift; sourceTree = ""; }; ED76F12EBF9C7E7B9AA95142 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; ED7B674D586540F35682C573 /* SuggestionCoordinator+Prediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Prediction.swift"; sourceTree = ""; }; ED8672B87CEC72BE3978C6BB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1603,6 +1641,7 @@ F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; F7F727A325ECE687B32CDC38 /* Aria2ProvisionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2ProvisionerTests.swift; sourceTree = ""; }; F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; + F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryPhrasePredictor.swift; sourceTree = ""; }; FA4364402391C8A8D9B3FCEC /* PhrasePredictionScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoring.swift; sourceTree = ""; }; FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; FACB1195A78B524AB65D2EF4 /* FoundationModelAvailabilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityServiceTests.swift; sourceTree = ""; }; @@ -1677,6 +1716,7 @@ children = ( 589F9E194F78CE0E5FF8F495 /* Context */, DD63AF32089738F21797C4D7 /* Focus */, + 3A7EDB0A4D9AA945105C75BB /* History */, 011A5248848010A06D9A182A /* Input */, 72E190A5A00C9BB76F46867A /* ModelManagement */, 3BCCC61805C656C1386B9287 /* Permission */, @@ -1983,6 +2023,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, + 9FEEB296E66D24C95D3DABAB /* TypingHistorySectionView.swift */, D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */, ); path = Panes; @@ -2067,6 +2108,14 @@ path = Context; sourceTree = ""; }; + 3A7EDB0A4D9AA945105C75BB /* History */ = { + isa = PBXGroup; + children = ( + 6C935BF65398E2BD0C62B6D8 /* TypingHistoryStoreTests.swift */, + ); + path = History; + sourceTree = ""; + }; 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */ = { isa = PBXGroup; children = ( @@ -2157,6 +2206,17 @@ path = Emoji; sourceTree = ""; }; + 4DDAC211BE3881C2187F0666 /* History */ = { + isa = PBXGroup; + children = ( + BE896AC5D329BC26BF99EAB8 /* CotypistExportImporterTests.swift */, + 503A3986FC8C378991727721 /* TypingHistoryIndexTests.swift */, + 7FB5276B7DDE61030A57EF9A /* TypingHistoryPhrasePredictorTests.swift */, + 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */, + ); + path = History; + sourceTree = ""; + }; 4F37A3A455E254F04FB255D6 /* Style */ = { isa = PBXGroup; children = ( @@ -2519,6 +2579,7 @@ D55AC5A7C15AD5E2917B168A /* Llama */, 3EFC2B112178CA99D6D76388 /* OpenAICompatible */, 6C034C1C9E51031A845D1184 /* SuggestionEngineRouterTests.swift */, + E8A00E909E0A51B119F9CB6B /* TypingHistoryPhraseEngineTests.swift */, ); path = Runtime; sourceTree = ""; @@ -2543,6 +2604,14 @@ path = Ranking; sourceTree = ""; }; + 7C7E899DE5F160C8E4340B46 /* History */ = { + isa = PBXGroup; + children = ( + 7CFDC85F1E37BF37FB4751A1 /* TypingHistoryModels.swift */, + ); + path = History; + sourceTree = ""; + }; 7CE2AB7F84E13EB0A98D263D /* Updates */ = { isa = PBXGroup; children = ( @@ -2657,6 +2726,7 @@ 39CD6D46EF4D9EE89761F724 /* Context */, C0079825564365520F908A23 /* Emoji */, A94BF2CB1EA33390CE76EB16 /* Focus */, + 7C7E899DE5F160C8E4340B46 /* History */, F02B51FBFE03C6BF20497A70 /* Input */, 566042A4D7A0EC38C0DE910F /* Onboarding */, F66C64436E0D9AFC2D3334D0 /* Permissions */, @@ -2766,6 +2836,7 @@ DEC0DDC1DF558A8932276294 /* Context */, 251BA8EDFCC71DCE20998F99 /* Emoji */, D2F864A6272D7DDD11AD4E7C /* Focus */, + 4DDAC211BE3881C2187F0666 /* History */, A3BA1847A60D5F0CD97195DA /* Input */, 14283EC2C587EED5D20875D3 /* Logging */, 6994DC8878D2BC1DFE0E7B80 /* Macros */, @@ -3123,6 +3194,17 @@ path = Onboarding; sourceTree = ""; }; + BF9705FD05F27E06346DBCDA /* History */ = { + isa = PBXGroup; + children = ( + 78AD40DD8E9C1C8968956B0E /* CotypistExportImporter.swift */, + DAC3203A94F736B83237FEC9 /* TypingHistoryIndex.swift */, + F9CE9C2EE56728151E6CE5C7 /* TypingHistoryPhrasePredictor.swift */, + 3880F8FF215912CD4C337F8E /* TypingHistoryScrubber.swift */, + ); + path = History; + sourceTree = ""; + }; BFDD0073B68616AB220BE0AE /* Acceptance */ = { isa = PBXGroup; children = ( @@ -3391,6 +3473,7 @@ children = ( 55E1A705ADAF153AE40216E9 /* Context */, 59681B24821B0C960A656168 /* Focus */, + FDE64F29B6370F667A52DCE5 /* History */, 1A748DD787833F29034EF2EA /* Input */, E9FA809AF98B9C97A5F472F6 /* ModelManagement */, 1F3BF5BD8958D0D0C2E34AF1 /* Permission */, @@ -3535,6 +3618,7 @@ 7FAA2090D6421D9BAC2CFD37 /* Context */, 4D07DA390100ECC4E8164547 /* Emoji */, D816DC011F025BC315F10052 /* Focus */, + BF9705FD05F27E06346DBCDA /* History */, CB1EE2A3FD527C8377056C38 /* Input */, A4EE6A8F2C4AB8940B72F681 /* Logging */, 67C78D77B58388B15AC8B954 /* Macros */, @@ -3614,6 +3698,15 @@ path = Output; sourceTree = ""; }; + FDE64F29B6370F667A52DCE5 /* History */ = { + isa = PBXGroup; + children = ( + 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */, + ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */, + ); + path = History; + sourceTree = ""; + }; FFF55D3CE9FF3B16845823F7 /* Runtime */ = { isa = PBXGroup; children = ( @@ -3622,6 +3715,7 @@ D1FD19C28ED1EA45A68ABE16 /* OpenAICompatible */, 2C8D50115291212D03414CBE /* DebugForcedSuggestionEngine.swift */, 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */, + 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */, ); path = Runtime; sourceTree = ""; @@ -3892,6 +3986,7 @@ AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, A5D76116479357C29E2D8405 /* CotabbyDebugOptions.swift in Sources */, + 6E978AD7E340B2795F120AC0 /* CotypistExportImporter.swift in Sources */, B803D0491F5CF19735F23B65 /* CurrencyEvaluator.swift in Sources */, 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */, 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */, @@ -4139,6 +4234,14 @@ F60A94747732F706FD666925 /* TypefaceEvidence.swift in Sources */, DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */, 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */, + DAD9C50637407AFA2620DBC9 /* TypingHistoryIndex.swift in Sources */, + FC61422DC8E8EBD10A8F439B /* TypingHistoryModels.swift in Sources */, + 6F18827DF0D0CC9ADCCA83C2 /* TypingHistoryPhraseEngine.swift in Sources */, + 4002A961699B7565A9DB5BF7 /* TypingHistoryPhrasePredictor.swift in Sources */, + E68459DC91D2F70D9D3D1A40 /* TypingHistoryScrubber.swift in Sources */, + 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */, + 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */, + 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */, BDF13BDA6D30BB8FF755AC32 /* TypingPredictionCandidate.swift in Sources */, C0468063222C5FEA7C1CFAB3 /* TypoCaseTransfer.swift in Sources */, A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */, @@ -4219,6 +4322,7 @@ FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, 7A31E6395C535FF017A1EFE1 /* CotabbyDebugOptions.swift in Sources */, + 5509B327A1C9E67467333B06 /* CotypistExportImporter.swift in Sources */, 1F39EE1D5FA0F5D32AFFB028 /* CurrencyEvaluator.swift in Sources */, EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */, C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, @@ -4466,6 +4570,14 @@ 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */, 9F8656C10B45DA6699A43787 /* TypefaceMatcher.swift in Sources */, F6A88AAC1A3FE7FB284BFC34 /* TypingCadence.swift in Sources */, + 8DCEEB9FCB3F7C90B7B4917F /* TypingHistoryIndex.swift in Sources */, + CAE609E1F40C3F1B7E4AAA43 /* TypingHistoryModels.swift in Sources */, + 56987393D95962CD49B4A4E8 /* TypingHistoryPhraseEngine.swift in Sources */, + AB15E751142E1FDB2F8A389F /* TypingHistoryPhrasePredictor.swift in Sources */, + 113710208D17C1998ED307E1 /* TypingHistoryScrubber.swift in Sources */, + AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */, + 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */, + 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */, 733A13BF72DCBBDC37952DD9 /* TypingPredictionCandidate.swift in Sources */, CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */, 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */, @@ -4533,6 +4645,7 @@ 57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */, F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, + DC3B4CF0634704EF2ADA7C94 /* CotypistExportImporterTests.swift in Sources */, 15BE5127E4BE29F6CBEEAA0E /* CurrencyEvaluatorTests.swift in Sources */, 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */, 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */, @@ -4741,6 +4854,11 @@ 01527F11AEDBE20935A254B6 /* TypefaceMatcherTests.swift in Sources */, FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */, BD09E1744CEF3EA688E6BB1D /* TypingExperienceEvalTests.swift in Sources */, + B695D8396E7FE07438DF9C9F /* TypingHistoryIndexTests.swift in Sources */, + 2CDE0BF9AF320B269C82E937 /* TypingHistoryPhraseEngineTests.swift in Sources */, + 8E236047C490CD266E603823 /* TypingHistoryPhrasePredictorTests.swift in Sources */, + 5619C761998CCBB3FD50CBDB /* TypingHistoryScrubberTests.swift in Sources */, + D5BDC97A559072C580DAE0FB /* TypingHistoryStoreTests.swift in Sources */, 71676E29789ACF4FE7925220 /* TypingPredictionCandidateTests.swift in Sources */, FFC99A497F09204BC9B118FD /* TypingSessionEvalScoring.swift in Sources */, 5C67947575E96AABF06B1AC9 /* TypingSessionEvalScoringTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 5535fec0..77a288b4 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -25,6 +25,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let systemMetricsStore: SystemMetricsStore private let onShowWelcome: () -> Void private let clearEmojiHistory: () -> Void + private let typingHistoryStore: TypingHistoryStore private var settingsWindowController: NSWindowController? @@ -49,7 +50,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { qualityMetricsStore: SuggestionQualityMetricsStore, systemMetricsStore: SystemMetricsStore, onShowWelcome: @escaping () -> Void, - clearEmojiHistory: @escaping () -> Void + clearEmojiHistory: @escaping () -> Void, + typingHistoryStore: TypingHistoryStore ) { self.appUpdateManager = appUpdateManager self.permissionManager = permissionManager @@ -65,6 +67,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.systemMetricsStore = systemMetricsStore self.onShowWelcome = onShowWelcome self.clearEmojiHistory = clearEmojiHistory + self.typingHistoryStore = typingHistoryStore } /// Shows the settings window, reusing the existing instance if it is already open. @@ -94,6 +97,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { systemMetricsStore: systemMetricsStore, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory, + typingHistoryStore: typingHistoryStore, onQuit: { NSApplication.shared.terminate(nil) } ) ) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift index 776217d8..7660123e 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Continuation.swift @@ -59,7 +59,8 @@ extension SuggestionCoordinator { let request = SuggestionRequestFactory.buildRequest(context: context, settings: settingsSnapshot, configuration: configuration, clipboardContext: pinnedClipboardContext(rawContext: rawContext), visualContextSummary: permissionManager.screenRecordingGranted - ? visualContextCoordinator.excerpt(for: session.baseContext) : nil).request + ? visualContextCoordinator.excerpt(for: session.baseContext) : nil, + historyExamples: historyExamples(for: context)).request continuationWorkController.replaceDebouncedWork(delayMilliseconds: 0) { [weak self] workID in guard let self else { return } await self.awaitCachedGenerationContextResetIfNeeded() diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index c0730e75..9a8a2531 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -223,7 +223,8 @@ extension SuggestionCoordinator { let request = SuggestionRequestFactory.buildRequest( context: prewarmContext, settings: settings, - configuration: configuration + configuration: configuration, + historyExamples: self.historyExamples(for: prewarmContext) ).request await suggestionEngine.prewarm(for: request) } diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 3df7bcc2..3853fd63 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -126,7 +126,8 @@ extension SuggestionCoordinator { settings: settingsSnapshot, configuration: configuration, clipboardContext: clipboardContext, - visualContextSummary: visualContextSummary + visualContextSummary: visualContextSummary, + historyExamples: historyExamples(for: context) ) latestGenerationNumber = context.generation let request = requestBuildResult.request @@ -288,7 +289,8 @@ extension SuggestionCoordinator { settings: settingsSnapshot, configuration: configuration, clipboardContext: clipboardContext, - visualContextSummary: visualContextSummary + visualContextSummary: visualContextSummary, + historyExamples: historyExamples(for: context) ) latestGenerationNumber = context.generation let request = requestBuildResult.request @@ -359,7 +361,8 @@ extension SuggestionCoordinator { clipboardContext: pinnedClipboardContext(rawContext: optimistic), visualContextSummary: permissionManager.screenRecordingGranted ? visualContextCoordinator.excerpt(for: context) - : nil + : nil, + historyExamples: historyExamples(for: context) ) let request = requestBuildResult.request let suggestionEngine = suggestionEngine diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index 227073e9..cf3edcde 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -174,6 +174,17 @@ final class SuggestionCoordinator: ObservableObject { /// coordinator continues to own the timer and input-monitor effects around these transitions. var postExhaustionAcceptanceState = PostExhaustionAcceptanceState() + /// The user's typing history, when the app has one. Optional so test rigs and previews run + /// without it; the provider itself returns nothing while history is turned off. + let historyProvider: (any SuggestionHistoryProviding)? + + /// Examples of the user's past writing for this field, for every request built from it. + /// Every request kind (ordinary, speculative, continuation, prewarm) passes the same examples so + /// their prompts share one head and the llama KV cache stays reusable between them. + func historyExamples(for context: FocusedInputContext) -> [String] { + historyProvider?.historyExamples(for: context, engine: settingsSnapshot.selectedEngine) ?? [] + } + init( permissionManager: any SuggestionPermissionProviding, lowPowerModeProvider: any SuggestionLowPowerModeProviding, @@ -193,6 +204,7 @@ final class SuggestionCoordinator: ObservableObject { symSpellCorrector: SymSpellCorrector, spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(), qualityMetricsStore: SuggestionQualityMetricsStore, + historyProvider: (any SuggestionHistoryProviding)? = nil, userDefaults: UserDefaults = .standard ) { let storedTotalTabAcceptedWordCount = userDefaults.integer( @@ -216,6 +228,7 @@ final class SuggestionCoordinator: ObservableObject { self.symSpellCorrector = symSpellCorrector self.spellingLanguageResolver = spellingLanguageResolver self.qualityMetricsStore = qualityMetricsStore + self.historyProvider = historyProvider self.userDefaults = userDefaults settingsSnapshot = suggestionSettings.snapshot // These collaborators isolate "how overlay/logging works" from "when the coordinator diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index e29c9e63..cae2d0fd 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -247,6 +247,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { activationIndicatorController.hide(reason: "Activation indicator hidden because Cotabby is terminating.") focusDebugOverlayController?.hide() suggestionCoordinator.stop() + // Write the field being typed in now; the debounced background save may not have run yet. + environment.typingHistoryStore.flush() inlineCommandCoordinator.stop() inputMonitor.stop() focusModel.stop() diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index ee4a1618..3a938801 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -40,6 +40,7 @@ final class CotabbyAppEnvironment { let huggingFaceSearchService: HuggingFaceSearchService let performanceMetricsStore: PerformanceMetricsStore let qualityMetricsStore: SuggestionQualityMetricsStore + let typingHistoryStore: TypingHistoryStore let settingsCoordinator: SettingsCoordinator let activationIndicatorController: ActivationIndicatorController let focusDebugOverlayController: FocusDebugOverlayController? @@ -223,9 +224,20 @@ final class CotabbyAppEnvironment { ) // Under `-cotabby-debug` with `cotabbyDebugForcedSuggestion` set, every request answers with // that fixed text so ghost placement can be measured deterministically without a model. + // Typing history owns its own encrypted archive. Inside the XCTest host it starts empty and + // never opens the real archive or Keychain item, so tests cannot read or overwrite it. + let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + let typingHistoryStore = TypingHistoryStore(loadsArchive: !isTestHost) + // Phrase shortcuts answer from history before the router runs. The live engine kind is + // read per request so a power-source switch to the endpoint stops shortcuts immediately. + let historyAwareEngine = TypingHistoryPhraseEngine( + wrapping: routedEngine, + history: typingHistoryStore, + engineKind: { [weak suggestionSettings] in suggestionSettings?.selectedEngine ?? .openAICompatible } + ) let suggestionEngine: any SuggestionGenerating = DebugForcedSuggestionEngine.isConfigured() - ? DebugForcedSuggestionEngine(wrapping: routedEngine) - : routedEngine + ? DebugForcedSuggestionEngine(wrapping: historyAwareEngine) + : historyAwareEngine // Per-user emoji recents/frequency. Built before the settings coordinator so the // "Clear History" control can reach it, and before the picker which reads and writes it. @@ -247,7 +259,8 @@ final class CotabbyAppEnvironment { onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() }, - clearEmojiHistory: { emojiUsageStore.clear() } + clearEmojiHistory: { emojiUsageStore.clear() }, + typingHistoryStore: typingHistoryStore ) let interactionState = SuggestionInteractionState() @@ -282,7 +295,8 @@ final class CotabbyAppEnvironment { spellChecker: spellChecker, symSpellCorrector: symSpellCorrector, spellingLanguageResolver: SpellingLanguageResolver(), - qualityMetricsStore: qualityMetricsStore + qualityMetricsStore: qualityMetricsStore, + historyProvider: typingHistoryStore ) // The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor, @@ -352,12 +366,27 @@ final class CotabbyAppEnvironment { self.huggingFaceSearchService = huggingFaceSearchService self.performanceMetricsStore = performanceMetricsStore self.qualityMetricsStore = qualityMetricsStore + self.typingHistoryStore = typingHistoryStore self.settingsCoordinator = settingsCoordinator self.activationIndicatorController = activationIndicatorController self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable ? FocusDebugOverlayController() : nil + // Recording reads every focus snapshot; the store ignores them unless recording is on and + // the text changed. Cotabby's own gates (globally on, not paused, app not disabled) decide + // where recording may happen, so history is only collected where Cotabby is active. + focusModel.$snapshot + .sink { [weak typingHistoryStore, weak suggestionSettings] snapshot in + guard let typingHistoryStore, let suggestionSettings else { return } + typingHistoryStore.observe(snapshot) { + let settings = suggestionSettings.snapshot + return settings.isGloballyEnabled && !settings.isTemporarilyPaused + && !(snapshot.bundleIdentifier.map(settings.disabledAppBundleIdentifiers.contains) ?? false) + } + } + .store(in: &cancellables) + // Update the AX polling timer whenever the user changes the poll interval setting. suggestionSettings.$focusPollIntervalMilliseconds .removeDuplicates() diff --git a/Cotabby/Models/History/TypingHistoryModels.swift b/Cotabby/Models/History/TypingHistoryModels.swift new file mode 100644 index 00000000..e8a0264a --- /dev/null +++ b/Cotabby/Models/History/TypingHistoryModels.swift @@ -0,0 +1,72 @@ +import Foundation + +/// File overview: +/// Value types for Cotabby's typing history: the text the user has written in fields Cotabby was +/// active in (recorded on this Mac, or imported from another autocomplete app), and the user's +/// preferences for collecting and using it. +/// +/// Why a separate subsystem: history is the only Cotabby data that outlives the field it came from. +/// Everything else in a request (caret text, clipboard, screen) is ephemeral. Keeping these values +/// apart from `SuggestionSettingsModel` lets the history store own its own storage, encryption, and +/// lifecycle, while the suggestion pipeline only sees a narrow read contract +/// (`SuggestionHistoryProviding`). + +/// One field's worth of the user's writing. +/// +/// A record is updated in place while the user keeps typing in the same field, so a long email is +/// one record rather than one per keystroke. `text` is already scrubbed of secret-like tokens +/// (`TypingHistoryScrubber`) before it is stored. +nonisolated struct TypingHistoryRecord: Codable, Equatable, Sendable, Identifiable { + enum Source: String, Codable, Sendable { + /// Captured by Cotabby while the user typed. + case recorded + /// Brought in from another app's export (for example Cotypist). + case imported + } + + let id: UUID + let bundleIdentifier: String + /// Registrable web domain for browser fields ("claude.ai"), nil for native apps. + let domain: String? + let createdAt: Date + var updatedAt: Date + var text: String + let source: Source + /// How many characters at the start of `text` were before the caret when it was captured. + /// Text after the caret is usually not the user's: in an email reply it is the quoted thread + /// other people wrote. Learning only from this part keeps their names and phrasing out of the + /// user's shortcuts. Nil means the whole text counts (records written before this existed). + var typedLength: Int? = nil + + /// The part of `text` the user wrote themselves. + var typedText: String { + guard let typedLength, typedLength < text.count else { return text } + return String(text.prefix(typedLength)) + } +} + +/// The encrypted file's plaintext payload. Versioned so a future format change can migrate rather +/// than silently dropping the user's history. +nonisolated struct TypingHistoryArchive: Codable, Equatable, Sendable { + static let currentVersion = 1 + + var version: Int + var records: [TypingHistoryRecord] +} + +/// The user's typing-history preferences, persisted by `TypingHistoryStore`. +nonisolated struct TypingHistoryPreferences: Equatable, Sendable { + /// Whether stored history shapes suggestions (prompt examples and phrase shortcuts). + var isUsingHistory: Bool + /// Whether new typing is recorded. Off by default: recording keeps the user's writing on disk, + /// which is a privacy decision the user makes, not a default Cotabby makes for them. + var isRecording: Bool + /// Apps whose fields are never recorded, by bundle identifier. + var excludedBundleIdentifiers: [String] + + static let defaults = TypingHistoryPreferences( + isUsingHistory: false, + isRecording: false, + excludedBundleIdentifiers: [] + ) +} diff --git a/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift b/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift index f5bc963c..5970b578 100644 --- a/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift +++ b/Cotabby/Models/Suggestion/Request/SuggestionRequest.swift @@ -72,6 +72,10 @@ struct SuggestionRequest: Equatable, Sendable { /// prompt has already folded it in; this field exists so the Foundation Models renderer can /// state the same sanitized facts in its own prompt shape. let surfaceContext: SurfaceContext? + /// Passages of the user's own past writing that resemble this field (see `TypingHistoryStore`). + /// Always empty for the endpoint engine: history never leaves this Mac, and the router refuses + /// to send a request that carries any. + let historyExamples: [String] /// When enabled, the normalizer keeps multiple lines instead of truncating to the first line. let isMultiLineEnabled: Bool /// The user's word-count preset, so decoding does not stop at a sentence end before the minimum @@ -104,6 +108,7 @@ struct SuggestionRequest: Equatable, Sendable { clipboardContext: String?, visualContextSummary: String?, surfaceContext: SurfaceContext? = nil, + historyExamples: [String] = [], isMultiLineEnabled: Bool, requestID: String = "req_unknown", wordRange: SuggestionWordRange? = nil @@ -128,6 +133,7 @@ struct SuggestionRequest: Equatable, Sendable { self.clipboardContext = clipboardContext self.visualContextSummary = visualContextSummary self.surfaceContext = surfaceContext + self.historyExamples = historyExamples self.isMultiLineEnabled = isMultiLineEnabled self.requestID = requestID self.wordRange = wordRange diff --git a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift index 9c88d684..f6dd2871 100644 --- a/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/Suggestion/SuggestionSubsystemContracts.swift @@ -101,6 +101,19 @@ protocol EmojiInputIntercepting: AnyObject { func isWordAcceptKey(_ keyEvent: InputMonitorKeyEvent) -> Bool } +/// Read-only access to the user's typing history for the suggestion pipeline. +/// +/// Both answers are empty for the endpoint engine: history stays on this Mac, so it may only shape +/// requests handled by Apple Intelligence or the in-process model. Implementations also return +/// nothing while the user has history turned off, so callers never need to check settings. +@MainActor +protocol SuggestionHistoryProviding: AnyObject { + /// Short passages of the user's past writing that resemble the current field, best first. + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] + /// Exact text to insert when history is confident how the current phrase ends, else nil. + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? +} + @MainActor protocol SuggestionGenerating: AnyObject { func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift new file mode 100644 index 00000000..f4940938 --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -0,0 +1,369 @@ +import Combine +import Foundation +import Logging + +/// Owns the user's typing history: its preferences, the encrypted archive, recording new typing, and +/// the search structures that turn history into prompt examples and phrase shortcuts. +/// +/// Ownership: built once by `CotabbyAppEnvironment` and kept for the app's lifetime. The suggestion +/// coordinator and the phrase engine read it through `SuggestionHistoryProviding`; the Context +/// settings pane observes it directly (`@ObservedObject`) for its toggles, counts, Import, and +/// Delete All. Its preferences live here rather than in `SuggestionSettingsModel` because they only +/// matter to this subsystem, and keeping them together keeps Delete All and the recording gate in +/// one place. +/// +/// Concurrency: everything mutable is `@MainActor`. Expensive work runs on detached tasks against +/// value copies: decrypting and encrypting the archive (`TypingHistoryVault`) and building the index +/// and phrase table. Each rebuild carries a generation number so a slow, older build can never +/// replace a newer one. +@MainActor +final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { + enum Status: Equatable { + case loading + case ready + /// The archive exists but could not be opened. Recording and import stay off so the + /// unreadable file is never overwritten with a smaller one. + case unavailable(String) + } + + @Published private(set) var preferences: TypingHistoryPreferences + @Published private(set) var recordCount = 0 + @Published private(set) var status: Status = .loading + @Published private(set) var isImporting = false + @Published private(set) var lastImportMessage: String? + + /// Oldest records are dropped past this many. Retrieval and the phrase table stay small enough + /// to rebuild in a second or two, and very old writing says little about how the user writes now. + static let maximumRecords = 20_000 + /// Fields with less text than this are not worth keeping ("ok", a search term). + static let minimumRecordedCharacters = 20 + + private let vault: TypingHistoryVault + private let userDefaults: UserDefaults + private var records: [TypingHistoryRecord] = [] + private var index: TypingHistoryIndex? + private var phrases: TypingHistoryPhrasePredictor? + private var rebuildGeneration = 0 + private var saveTask: Task? + private var activeRecording: ActiveRecording? + /// The field most recently finished. Accessibility can briefly report a field as unsupported + /// mid-typing; when the same field comes back, recording resumes into the same record instead + /// of starting a duplicate. + private var lastFinishedRecording: ActiveRecording? + private var exampleCache: (key: String, examples: [String])? + + /// The field being typed in right now. Its raw text is kept here and only scrubbed and copied + /// into `records` when saving or when focus moves on, so recording costs a string comparison + /// per keystroke rather than a regex pass over the whole field. + private struct ActiveRecording { + let fieldKey: String + let recordID: UUID + let bundleIdentifier: String + let domain: String? + let createdAt: Date + var rawText: String + /// Characters before the caret in `rawText` at the latest capture. + var rawTypedLength: Int + } + + private enum DefaultsKey { + static let isUsingHistory = "cotabbyTypingHistoryEnabled" + static let isRecording = "cotabbyTypingHistoryRecordingEnabled" + static let excludedBundleIdentifiers = "cotabbyTypingHistoryExcludedApps" + } + + init(vault: TypingHistoryVault = .standard(), userDefaults: UserDefaults = .standard, loadsArchive: Bool = true) { + self.vault = vault + self.userDefaults = userDefaults + preferences = TypingHistoryPreferences( + isUsingHistory: userDefaults.object(forKey: DefaultsKey.isUsingHistory) as? Bool + ?? TypingHistoryPreferences.defaults.isUsingHistory, + isRecording: userDefaults.object(forKey: DefaultsKey.isRecording) as? Bool + ?? TypingHistoryPreferences.defaults.isRecording, + excludedBundleIdentifiers: userDefaults.stringArray(forKey: DefaultsKey.excludedBundleIdentifiers) ?? [] + ) + if loadsArchive { + Task { await loadArchive() } + } else { + status = .ready + } + } + + // MARK: - Preferences + + func setUsingHistory(_ enabled: Bool) { + guard preferences.isUsingHistory != enabled else { return } + preferences.isUsingHistory = enabled + userDefaults.set(enabled, forKey: DefaultsKey.isUsingHistory) + exampleCache = nil + } + + func setRecording(_ enabled: Bool) { + guard preferences.isRecording != enabled else { return } + preferences.isRecording = enabled + userDefaults.set(enabled, forKey: DefaultsKey.isRecording) + if !enabled { finishActiveRecording() } + } + + func setExcluded(_ bundleIdentifier: String, excluded: Bool) { + var identifiers = preferences.excludedBundleIdentifiers.filter { $0 != bundleIdentifier } + if excluded { identifiers.append(bundleIdentifier) } + identifiers.sort() + guard identifiers != preferences.excludedBundleIdentifiers else { return } + preferences.excludedBundleIdentifiers = identifiers + userDefaults.set(identifiers, forKey: DefaultsKey.excludedBundleIdentifiers) + if excluded, activeRecording?.bundleIdentifier == bundleIdentifier { + // Excluding an app mid-field discards that field's unsaved text instead of keeping it. + activeRecording = nil + lastFinishedRecording = nil + } + } + + // MARK: - Loading, saving, rebuilding + + func loadArchive() async { + let vault = vault + do { + let loaded = try await Task.detached(priority: .utility) { try vault.load() }.value + records = loaded + recordCount = loaded.count + status = .ready + rebuildSearchStructures() + } catch { + status = .unavailable("Typing history couldn't be opened, so it's paused to avoid overwriting it.") + CotabbyLogger.app.error("Typing history archive could not be loaded: \(error)") + } + } + + /// Writes immediately, on the calling (main) thread. Used at termination, when there is no time + /// left for a debounced background save. + func flush() { + guard status == .ready else { return } + saveTask?.cancel() + materializeActiveRecording() + do { + try vault.save(records) + } catch { + CotabbyLogger.app.error("Typing history could not be saved: \(error)") + } + } + + private func scheduleSave() { + guard status == .ready else { return } + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !Task.isCancelled else { return } + self.materializeActiveRecording() + let snapshot = self.records + let vault = self.vault + do { + try await Task.detached(priority: .utility) { try vault.save(snapshot) }.value + } catch { + CotabbyLogger.app.error("Typing history could not be saved: \(error)") + } + } + } + + /// Rebuilds the index and phrase table off the main actor from a copy of the records. The field + /// being typed in is left out so the user's unfinished draft never becomes its own example. + private func rebuildSearchStructures() { + rebuildGeneration += 1 + let generation = rebuildGeneration + let activeID = activeRecording?.recordID + let snapshot = records.filter { $0.id != activeID } + Task { [weak self] in + let built = await Task.detached(priority: .utility) { + (TypingHistoryIndex(records: snapshot), TypingHistoryPhrasePredictor(records: snapshot)) + }.value + guard let self, generation == self.rebuildGeneration else { return } + self.index = built.0 + self.phrases = built.1 + self.exampleCache = nil + } + } + + // MARK: - Recording + + /// Called for every focus snapshot. Cheap unless the field's text changed. + /// + /// `isAllowed` carries Cotabby's own gates (globally on, not paused, app not disabled), so + /// history is only ever recorded where Cotabby itself is active. It is a closure because it + /// builds a settings snapshot, and this runs on every focus poll: it is only evaluated once + /// recording is on. Secure fields never reach here as supported, and are checked again below. + func observe(_ snapshot: FocusSnapshot, isAllowed: () -> Bool) { + guard status == .ready, preferences.isRecording, + case .supported = snapshot.capability, + let input = snapshot.context, !input.isSecure, + !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier), + isAllowed() + else { + finishActiveRecording() + return + } + + let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)|\(input.focusChangeSequence)" + let text = input.precedingText + input.trailingText + if activeRecording?.fieldKey != fieldKey { + finishActiveRecording() + if var resumed = lastFinishedRecording, resumed.fieldKey == fieldKey { + resumed.rawText = text + resumed.rawTypedLength = input.precedingText.count + activeRecording = resumed + } else { + activeRecording = ActiveRecording( + fieldKey: fieldKey, + recordID: UUID(), + bundleIdentifier: input.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: input.focusedURLString), + createdAt: Date(), + rawText: text, + rawTypedLength: input.precedingText.count + ) + } + return + } + guard activeRecording?.rawText != text else { return } + activeRecording?.rawText = text + activeRecording?.rawTypedLength = input.precedingText.count + scheduleSave() + } + + /// Commits the active field to `records` and makes it searchable. Called when focus moves to + /// another field, recording stops, or the app is no longer eligible. + private func finishActiveRecording() { + guard activeRecording != nil else { return } + let changed = materializeActiveRecording() + lastFinishedRecording = activeRecording + activeRecording = nil + if changed { + scheduleSave() + rebuildSearchStructures() + } + } + + /// Copies the active field's scrubbed text into `records`. Returns whether anything changed. + @discardableResult + private func materializeActiveRecording() -> Bool { + guard let active = activeRecording else { return false } + let split = active.rawText.index(active.rawText.startIndex, offsetBy: min(active.rawTypedLength, active.rawText.count)) + let (text, typedLength) = TypingHistoryScrubber.scrub( + before: String(active.rawText[..= Self.minimumRecordedCharacters else { + // The user cleared the field down to nothing worth keeping. + if let existingIndex { + records.remove(at: existingIndex) + recordCount = records.count + return true + } + return false + } + if let existingIndex { + guard records[existingIndex].text != text || records[existingIndex].typedLength != typedLength else { + return false + } + records[existingIndex].text = text + records[existingIndex].typedLength = typedLength + records[existingIndex].updatedAt = Date() + } else { + records.append(TypingHistoryRecord( + id: active.recordID, bundleIdentifier: active.bundleIdentifier, domain: active.domain, + createdAt: active.createdAt, updatedAt: Date(), text: text, source: .recorded, + typedLength: typedLength + )) + trimToCapacity() + } + recordCount = records.count + return true + } + + private func trimToCapacity() { + guard records.count > Self.maximumRecords else { return } + records.sort { $0.updatedAt < $1.updatedAt } + records.removeFirst(records.count - Self.maximumRecords) + } + + // MARK: - Import and deletion + + /// Imports a Cotypist `user_inputs.json` export. Entries whose text is already in history are + /// skipped, so importing the same file twice adds nothing. + func importCotypistExport(from url: URL) async { + guard status == .ready, !isImporting else { return } + isImporting = true + defer { isImporting = false } + do { + let imported = try await Task.detached(priority: .userInitiated) { + try CotypistExportImporter.records(fromExport: Data(contentsOf: url)) + }.value + let knownTexts = Set(records.map(\.text)) + let fresh = imported.filter { !knownTexts.contains($0.text) } + records.append(contentsOf: fresh) + trimToCapacity() + recordCount = records.count + lastImportMessage = "Imported \(fresh.count) entries" + + (imported.count > fresh.count ? " (\(imported.count - fresh.count) were already in your history)." : ".") + scheduleSave() + rebuildSearchStructures() + } catch { + lastImportMessage = (error as? LocalizedError)?.errorDescription ?? "Import failed: \(error.localizedDescription)" + } + } + + /// Removes every record, the encrypted file, and its Keychain key. + func deleteAll() { + saveTask?.cancel() + activeRecording = nil + lastFinishedRecording = nil + records = [] + recordCount = 0 + index = nil + phrases = nil + exampleCache = nil + rebuildGeneration += 1 + lastImportMessage = nil + do { + try vault.destroy() + status = .ready + } catch { + CotabbyLogger.app.error("Typing history could not be deleted: \(error)") + } + } + + // MARK: - SuggestionHistoryProviding + + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] { + guard preferences.isUsingHistory, engine != .openAICompatible, let index else { return [] } + let stableText = TypingHistoryQuery.stableText(from: context.precedingText) + // The window title (an email subject, a document name) is the most stable topical signal a + // field has, so it joins the query even before the first full block of words is typed. + let queryText = [stableText, context.windowTitle ?? ""].joined(separator: " ") + let cacheKey = "\(context.focusedInputIdentityKey)|\(queryText)" + if let exampleCache, exampleCache.key == cacheKey { return exampleCache.examples } + + let examples = index.examples(for: TypingHistoryQuery( + text: queryText, + bundleIdentifier: context.bundleIdentifier, + domain: SurfaceContextComposer.registrableDomain(from: context.focusedURLString), + currentFieldText: context.precedingText + )) + exampleCache = (cacheKey, examples) + return examples + } + + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? { + guard preferences.isUsingHistory, engine != .openAICompatible, let phrases else { return nil } + // Text after the caret on the same line would be pushed along by an inserted phrase; leave + // those mid-line positions to the model, which sees the trailing text. + let restOfLine = request.context.trailingText.prefix { !$0.isNewline } + guard restOfLine.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + return phrases.continuation( + after: request.context.precedingText, + limits: TypingHistoryPhrasePredictor.Limits( + maxWords: request.wordRange?.highWords ?? 8, + allowsNewlines: request.isMultiLineEnabled + ) + ) + } +} diff --git a/Cotabby/Services/History/TypingHistoryVault.swift b/Cotabby/Services/History/TypingHistoryVault.swift new file mode 100644 index 00000000..c141171e --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryVault.swift @@ -0,0 +1,131 @@ +import CryptoKit +import Foundation +import Security + +/// Encrypted on-disk storage for typing history. +/// +/// Why encryption: the archive holds months of what the user wrote. Sealing it with AES-GCM under a +/// random 256-bit key that lives only in the login Keychain means a copied file, a backup, or another +/// app reading Application Support sees ciphertext. The Keychain item is `ThisDeviceOnly`, so the key +/// never syncs and the archive cannot be opened on another Mac. +/// +/// The vault is a value type with no shared mutable state, so the store can load and save it on a +/// background task without touching the main actor. Errors are surfaced rather than swallowed: a +/// failed decrypt must not be mistaken for "no history" and then overwritten. +nonisolated struct TypingHistoryVault: Sendable { + enum VaultError: Error, Equatable { + case keychain(OSStatus) + case corruptArchive + } + + let fileURL: URL + private let keyStore: any TypingHistoryKeyStore + + init(fileURL: URL, keyStore: any TypingHistoryKeyStore) { + self.fileURL = fileURL + self.keyStore = keyStore + } + + /// The production vault: `Application Support//TypingHistory.sealed`, keyed per bundle + /// identifier so the release app and the dev build never share (or clobber) each other's key. + static func standard(bundle: Bundle = .main) -> TypingHistoryVault { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") + let appName = (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "Cotabby" + let identifier = bundle.bundleIdentifier ?? "com.jacobfu.tabby" + return TypingHistoryVault( + fileURL: support.appendingPathComponent(appName).appendingPathComponent("TypingHistory.sealed"), + keyStore: KeychainTypingHistoryKeyStore(service: "\(identifier).typing-history") + ) + } + + /// Returns the stored records, or an empty list when nothing has been saved yet. + func load() throws -> [TypingHistoryRecord] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + guard let key = try keyStore.existingKey() else { + // A file without its key can never be opened again; report it instead of returning [] + // so the caller does not overwrite it as if it were empty. + throw VaultError.corruptArchive + } + let sealed = try Data(contentsOf: fileURL) + guard let box = try? AES.GCM.SealedBox(combined: sealed), + let plaintext = try? AES.GCM.open(box, using: key), + let archive = try? JSONDecoder().decode(TypingHistoryArchive.self, from: plaintext) + else { + throw VaultError.corruptArchive + } + return archive.records + } + + func save(_ records: [TypingHistoryRecord]) throws { + let key = try keyStore.existingKey() ?? keyStore.createKey() + let plaintext = try JSONEncoder().encode(TypingHistoryArchive(version: TypingHistoryArchive.currentVersion, records: records)) + guard let sealed = try AES.GCM.seal(plaintext, using: key).combined else { throw VaultError.corruptArchive } + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try sealed.write(to: fileURL, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + + /// Deletes the archive and its key. Removing the key too means even an undeleted copy of the + /// file (Time Machine, a sync folder) can no longer be decrypted. + func destroy() throws { + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + try keyStore.deleteKey() + } +} + +/// Where the vault's symmetric key lives. A protocol so tests can keep keys in memory instead of +/// writing items into the developer's login Keychain. +nonisolated protocol TypingHistoryKeyStore: Sendable { + func existingKey() throws -> SymmetricKey? + func createKey() throws -> SymmetricKey + func deleteKey() throws +} + +/// The production key store: one generic-password item in the login Keychain. +nonisolated struct KeychainTypingHistoryKeyStore: TypingHistoryKeyStore { + let service: String + private static let account = "archive-key" + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: Self.account + ] + } + + func existingKey() throws -> SymmetricKey? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + // `item` is a CFData when the query asks for data; bridging to Data copies the bytes. + guard status == errSecSuccess, let data = item as? Data, data.count == 32 else { + throw TypingHistoryVault.VaultError.keychain(status) + } + return SymmetricKey(data: data) + } + + func createKey() throws -> SymmetricKey { + let key = SymmetricKey(size: .bits256) + var attributes = baseQuery + attributes[kSecValueData as String] = key.withUnsafeBytes { Data($0) } + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + attributes[kSecAttrLabel as String] = "Cotabby typing history key" + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { throw TypingHistoryVault.VaultError.keychain(status) } + return key + } + + func deleteKey() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw TypingHistoryVault.VaultError.keychain(status) + } + } +} diff --git a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift index 0c184347..4add2ca4 100644 --- a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift +++ b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift @@ -82,6 +82,16 @@ final class SuggestionEngineRouter { recordQualityOutcome(result) return result case .openAICompatible: + // The request was built from a settings snapshot; power-source switching can move the + // live engine to the endpoint in between. Typing history must never leave this Mac, so + // a request that carries it is dropped rather than sent. + guard request.historyExamples.isEmpty else { + CotabbyLogger.suggestion.info("Withheld a request carrying typing history from the endpoint", metadata: metadata) + return SuggestionResult( + generation: request.generation, rawText: "", text: "", latency: 0, + suppressionReason: "historyWithheldFromEndpoint" + ) + } CotabbyLogger.suggestion.debug("Routing to OpenAI-compatible endpoint", metadata: metadata) let result = try await openAICompatibleEngine.generateSuggestion(for: request, onPartial: onPartial) recordPerformanceMetric(modelName: endpointModelNameProvider() ?? "Local Endpoint", latency: result.latency) diff --git a/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift b/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift new file mode 100644 index 00000000..e41150de --- /dev/null +++ b/Cotabby/Services/Runtime/TypingHistoryPhraseEngine.swift @@ -0,0 +1,64 @@ +import Foundation +import Logging + +/// Answers a request straight from typing history when history knows how the phrase ends, and +/// otherwise hands it to the real engine. +/// +/// Why a wrapper around the router: a phrase shortcut is just another way to produce a +/// `SuggestionResult`, so the coordinator, overlay, and acceptance code need no changes to use it. +/// Sitting in front of `SuggestionEngineRouter` also means one check covers every engine, and the +/// live engine kind is read at request time so the endpoint never gets history-derived text. +/// +/// Lifetime: built once in `CotabbyAppEnvironment` around the router and owned by the coordinator +/// as its `suggestionEngine`. +@MainActor +final class TypingHistoryPhraseEngine: SuggestionGenerating { + private let base: any SuggestionGenerating + private let history: any SuggestionHistoryProviding + private let engineKind: @MainActor () -> SuggestionEngineKind + + init( + wrapping base: any SuggestionGenerating, + history: any SuggestionHistoryProviding, + engineKind: @escaping @MainActor () -> SuggestionEngineKind + ) { + self.base = base + self.history = history + self.engineKind = engineKind + } + + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + try await generateSuggestion(for: request, onPartial: nil) + } + + func generateSuggestion( + for request: SuggestionRequest, + onPartial: (@MainActor (SuggestionResult) -> Void)? + ) async throws -> SuggestionResult { + let started = ProcessInfo.processInfo.systemUptime + if let phrase = history.phraseContinuation(for: request, engine: engineKind()) { + CotabbyLogger.suggestion.debug( + "Answered from typing history", + metadata: ["request_id": .string(request.requestID), "engine": .string("history")] + ) + // The phrase is the user's own text, character for character, including the leading + // space, so the ghost renders it exactly instead of re-deciding the word boundary. + return SuggestionResult( + generation: request.generation, + rawText: phrase, + text: phrase, + latency: ProcessInfo.processInfo.systemUptime - started, + spacingIsExact: true + ) + } + return try await base.generateSuggestion(for: request, onPartial: onPartial) + } + + func resetCachedGenerationContext() async { + await base.resetCachedGenerationContext() + } + + func prewarm(for request: SuggestionRequest) async { + await base.prewarm(for: request) + } +} diff --git a/Cotabby/Support/History/CotypistExportImporter.swift b/Cotabby/Support/History/CotypistExportImporter.swift new file mode 100644 index 00000000..67d44c94 --- /dev/null +++ b/Cotabby/Support/History/CotypistExportImporter.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Converts a Cotypist typing-history export into Cotabby history records. +/// +/// Cotypist keeps its history in an encrypted database with no export button; the user's decrypted +/// copy is a JSON array of `user_inputs` rows (`textUpToCursor`, `textAfterCursor`, +/// `appBundleIdentifier`, `domain`, `createdAt`, ...). This type only maps that shape: it does no +/// file or Keychain access, so it is pure and testable. +/// +/// Cotypist stores a new row each time it snapshots the same field, so one email can appear dozens +/// of times as it grows. Rows are grouped by app plus the opening of the text, and only the longest +/// (most complete) version of each is kept; otherwise retrieval would return near-copies and phrase +/// counts would be inflated by repetition. +nonisolated enum CotypistExportImporter { + enum ImportError: Error, Equatable, LocalizedError { + case unrecognizedFormat + + var errorDescription: String? { + "This file isn't a Cotypist user_inputs export (a JSON list of typing-history rows)." + } + } + + private struct Row: Decodable { + let createdAt: String? + let updatedAt: String? + let appBundleIdentifier: String? + let textUpToCursor: String? + let textAfterCursor: String? + let domain: String? + } + + /// Texts shorter than this after scrubbing are fragments ("ok", "?") with nothing to learn. + static let minimumCharacters = 20 + private static let groupingPrefixLength = 40 + + static func records(fromExport data: Data) throws -> [TypingHistoryRecord] { + guard let rows = try? JSONDecoder().decode([Row].self, from: data) else { + throw ImportError.unrecognizedFormat + } + guard rows.isEmpty || rows.contains(where: { $0.textUpToCursor != nil }) else { + throw ImportError.unrecognizedFormat + } + + var longestByGroup: [String: TypingHistoryRecord] = [:] + for row in rows { + let (text, typedLength) = TypingHistoryScrubber.scrub( + before: row.textUpToCursor ?? "", after: row.textAfterCursor ?? "" + ) + guard text.trimmingCharacters(in: .whitespacesAndNewlines).count >= minimumCharacters else { continue } + let bundleIdentifier = row.appBundleIdentifier ?? "unknown" + let createdAt = parseDate(row.createdAt) ?? Date(timeIntervalSince1970: 0) + let record = TypingHistoryRecord( + id: UUID(), + bundleIdentifier: bundleIdentifier, + domain: normalizedDomain(row.domain), + createdAt: createdAt, + updatedAt: parseDate(row.updatedAt) ?? createdAt, + text: text, + source: .imported, + typedLength: typedLength + ) + let key = bundleIdentifier + "\u{1F}" + String(text.prefix(groupingPrefixLength)).lowercased() + if let existing = longestByGroup[key], existing.text.count >= text.count { continue } + longestByGroup[key] = record + } + return droppingEarlierSnapshots(Array(longestByGroup.values)).sorted { $0.createdAt < $1.createdAt } + } + + /// A field's early snapshots can be shorter than the grouping prefix ("Hi Arnaud, the POC is + /// read"), so they land in their own group. Within each app, drop any text that is the start of + /// a longer kept text: it is the same writing, caught before it was finished. + private static func droppingEarlierSnapshots(_ records: [TypingHistoryRecord]) -> [TypingHistoryRecord] { + var kept: [TypingHistoryRecord] = [] + for (_, appRecords) in Dictionary(grouping: records, by: \.bundleIdentifier) { + var keptInApp: [TypingHistoryRecord] = [] + for record in appRecords.sorted(by: { $0.text.count > $1.text.count }) + where !keptInApp.contains(where: { $0.text.hasPrefix(record.text) }) { + keptInApp.append(record) + } + kept.append(contentsOf: keptInApp) + } + return kept + } + + /// Cotypist uses "-" for fields with no site; treat that like no domain at all. + private static func normalizedDomain(_ domain: String?) -> String? { + guard let domain = domain?.trimmingCharacters(in: .whitespaces), !domain.isEmpty, domain != "-" else { + return nil + } + return domain + } + + /// The export writes SQLite-style timestamps ("2026-06-05 16:23:53.028") in UTC. + private static func parseDate(_ string: String?) -> Date? { + guard let string else { return nil } + for format in ["yyyy-MM-dd HH:mm:ss.SSS", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ"] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = format + if let date = formatter.date(from: string) { return date } + } + return nil + } +} diff --git a/Cotabby/Support/History/TypingHistoryIndex.swift b/Cotabby/Support/History/TypingHistoryIndex.swift new file mode 100644 index 00000000..0afa579f --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryIndex.swift @@ -0,0 +1,202 @@ +import Foundation + +/// What a retrieval looks for: the stable part of the text being written plus where it is written. +nonisolated struct TypingHistoryQuery: Equatable, Sendable { + /// Words that characterize the current writing (see `TypingHistoryQuery.stableText`). + let text: String + let bundleIdentifier: String + let domain: String? + /// The live field text. A history entry that contains its tail is the same document (an older + /// snapshot of this very field), and showing the model its own draft would only make it echo. + let currentFieldText: String + + /// Builds the query text from the caret prefix, rounded down to a whole block of words. + /// + /// The llama runtime reuses its KV cache for the unchanged head of the prompt, and examples sit + /// in that head. Querying on every keystroke would change the examples, and with them the cached + /// prefix, on nearly every request. Rounding to blocks of `wordsPerBlock` words keeps the + /// examples fixed while a few words are typed and refreshes them as the topic moves on. + static func stableText(from precedingText: String, wordsPerBlock: Int = 8, maxWords: Int = 48) -> String { + let words = precedingText.split(whereSeparator: \.isWhitespace) + let completeBlocks = (words.count / wordsPerBlock) * wordsPerBlock + guard completeBlocks > 0 else { return "" } + let window = words[..= Self.minimumDocumentCharacters else { continue } + let index = Int32(documents.count) + documents.append(Document(bundleIdentifier: record.bundleIdentifier, domain: record.domain, text: text)) + for term in Set(Self.terms(in: text)) { + postings[term, default: []].append(index) + } + } + let count = Double(documents.count) + self.documents = documents + self.postings = postings + // Smoothed IDF: every shared word counts at least 1, rarer words count more. Unsmoothed + // log(N/df) drops to zero for a word in every document, which in a small history made + // even a close match score nothing. + self.inverseDocumentFrequency = postings.mapValues { log((count + 1) / Double($0.count)) + 1 } + } + + /// Returns up to `limit` passages from past writing that best match the query, each at most + /// `maxCharacters` long, best first. + func examples(for query: TypingHistoryQuery, limit: Int = 2, maxCharacters: Int = 320) -> [String] { + let queryTerms = Set(Self.terms(in: query.text)) + guard !queryTerms.isEmpty, limit > 0 else { return [] } + + var scores: [Int32: Double] = [:] + for term in queryTerms { + guard let weight = inverseDocumentFrequency[term], let documentIDs = postings[term] else { continue } + for documentID in documentIDs { + scores[documentID, default: 0] += weight + } + } + + let currentTail = String(query.currentFieldText.suffix(60)).trimmingCharacters(in: .whitespacesAndNewlines) + let ranked = scores + .map { documentID, score -> (Int32, Double) in + let document = documents[Int(documentID)] + var boosted = score + if document.bundleIdentifier == query.bundleIdentifier { boosted *= Self.sameAppBoost } + if let domain = query.domain, document.domain == domain { boosted *= Self.sameDomainBoost } + return (documentID, boosted) + } + .filter { $0.1 >= Self.minimumScore } + .sorted { $0.1 > $1.1 } + + var passages: [String] = [] + for (documentID, _) in ranked { + let text = documents[Int(documentID)].text + if Self.isSameDocument(text, currentText: query.currentFieldText, currentTail: currentTail) { continue } + let passage = Self.bestPassage(in: text, terms: queryTerms, maxCharacters: maxCharacters) + guard !passage.isEmpty, !passages.contains(passage) else { continue } + passages.append(passage) + if passages.count == limit { break } + } + return passages + } + + /// True when a history entry is another version of the text being typed: an earlier snapshot + /// (the field now contains its opening) or a later one (it contains the field's latest words). + private static func isSameDocument(_ text: String, currentText: String, currentTail: String) -> Bool { + let opening = String(text.prefix(60)) + if opening.count >= 20, currentText.contains(opening) { return true } + return currentTail.count >= 20 && text.contains(currentTail) + } + + // MARK: - Text helpers + + private static let stopWords: Set = Set(""" + a an the and or but if then so to of in on at for with from by as is are was were be been being it its \ + this that these those i you he she we they me my your our their them us do does did not no yes can could \ + will would should have has had just also very more most some any all what which who when where why how \ + there here than too up out about into over after before again only own same other such each few both \ + one get got make made like well back still even way go going know think see want need please thanks \ + thank hi hello ok okay im dont ive ill let lets sure yeah + """.split(whereSeparator: \.isWhitespace).map(String.init)) + + /// Lowercased words of two or more letters or digits, minus stop words. + static func terms(in text: String) -> [String] { + text.lowercased() + .split(whereSeparator: { !($0.isLetter || $0.isNumber) }) + .filter { $0.count >= 2 } + .map(String.init) + .filter { !stopWords.contains($0) } + } + + /// Picks the sentence with the most query terms and grows it with its neighbors while it fits, + /// so the example reads as the user's own sentence rather than a mid-word cut. + private static func bestPassage(in text: String, terms: Set, maxCharacters: Int) -> String { + let sentences = splitSentences(text) + guard !sentences.isEmpty else { return "" } + let hits = sentences.map { sentence in Self.terms(in: sentence).filter(terms.contains).count } + guard let best = hits.indices.max(by: { hits[$0] < hits[$1] }) else { return "" } + + var lower = best + var upper = best + var length = sentences[best].count + while true { + let canGrowDown = lower > 0 && length + sentences[lower - 1].count + 1 <= maxCharacters + let canGrowUp = upper < sentences.count - 1 && length + sentences[upper + 1].count + 1 <= maxCharacters + if canGrowUp, !canGrowDown || hits[upper + 1] >= hits[lower - 1] { + upper += 1 + length += sentences[upper].count + 1 + } else if canGrowDown { + lower -= 1 + length += sentences[lower].count + 1 + } else { + break + } + } + let passage = sentences[lower...upper].joined(separator: " ") + guard passage.count > maxCharacters else { return passage } + // One very long sentence: keep whole words up to the cap. + var clipped = String(passage.prefix(maxCharacters)) + if let lastSpace = clipped.lastIndex(of: " ") { + clipped = String(clipped[.. [String] { + var sentences: [String] = [] + var current = "" + for character in text { + if character.isNewline { + appendTrimmed(current, to: &sentences) + current = "" + continue + } + current.append(character) + if ".!?".contains(character) { + appendTrimmed(current, to: &sentences) + current = "" + } + } + appendTrimmed(current, to: &sentences) + return sentences + } + + private static func appendTrimmed(_ sentence: String, to sentences: inout [String]) { + let trimmed = sentence.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + } +} diff --git a/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift new file mode 100644 index 00000000..94b01710 --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryPhrasePredictor.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Predicts the rest of a phrase the user has typed many times before, without a model. +/// +/// The idea: after "Best regards," the user writes "Senad" nearly every time; after "let me know" +/// they usually write "if you have any questions." A word-level n-gram table built from history +/// answers those instantly and in the user's exact wording, which a general model can only guess. +/// +/// It is intentionally strict. A continuation is offered only when the same three words were +/// followed by the same next word at least `minimumCount` times and in at least `minimumShare` of +/// cases, and the chain stops at the first word history is unsure about. Anything less confident +/// falls through to the model, so a shortcut is either clearly right or absent. +/// +/// Built off the main actor; `continuation(after:)` runs on the main actor per request and is a +/// handful of dictionary lookups. +nonisolated struct TypingHistoryPhrasePredictor: Sendable { + struct Limits: Equatable, Sendable { + var maxWords: Int + var allowsNewlines: Bool + } + + private static let contextLength = 3 + private static let minimumCount: Int32 = 3 + private static let minimumShare = 0.6 + /// When the last three words were never seen together, the last two are tried instead. Two + /// words predict less, so that fallback needs more evidence and a clearer winner. + private static let backoffMinimumCount: Int32 = 5 + private static let backoffMinimumShare = 0.7 + /// Marks a context word history has never seen, so the three-word lookup misses cleanly. + private static let unknownToken: Int32 = -1 + /// A one-word shortcut ("Senad" after "Best regards,") needs more evidence than a longer chain, + /// because a single common word is more likely to be a coincidence. + private static let minimumSingleWordCount: Int32 = 5 + private static let newlineToken = "\n" + + private struct Context: Hashable, Sendable { + let first: Int32 + let second: Int32 + let third: Int32 + } + + private struct ShortContext: Hashable, Sendable { + let first: Int32 + let second: Int32 + } + + /// Lowercased token -> id. Ids index `displayForms`. + private let vocabulary: [String: Int32] + /// The spelling to insert for each token, as the user last wrote it ("Senad", "POC"). + private let displayForms: [String] + /// Next-token counts for contexts seen at least `minimumCount` times. + private let continuations: [Context: [Int32: Int32]] + /// Two-word fallback, kept only for pairs seen at least `backoffMinimumCount` times. + private let shortContinuations: [ShortContext: [Int32: Int32]] + + var contextCount: Int { continuations.count } + + init(records: [TypingHistoryRecord]) { + var vocabulary: [String: Int32] = [:] + var displayForms: [String] = [] + var sequences: [[Int32]] = [] + sequences.reserveCapacity(records.count) + for record in records { + var ids: [Int32] = [] + // Learn only from what the user typed, so names and phrasing in quoted replies below the + // caret never become the user's shortcuts. + for token in Self.tokens(in: record.typedText) { + let key = token.lowercased() + if let id = vocabulary[key] { + displayForms[Int(id)] = token + ids.append(id) + } else { + let id = Int32(displayForms.count) + vocabulary[key] = id + displayForms.append(token) + ids.append(id) + } + } + sequences.append(ids) + } + + // Two passes keep memory bounded: count every context first, then collect next-token + // counts only for contexts frequent enough to ever produce a shortcut. + var contextCounts: [Context: Int32] = [:] + for ids in sequences where ids.count > Self.contextLength { + for index in Self.contextLength.. Self.contextLength { + for index in Self.contextLength..= Self.minimumCount else { continue } + continuations[context, default: [:]][ids[index], default: 0] += 1 + } + } + + var shortCounts: [ShortContext: Int32] = [:] + for ids in sequences where ids.count > 2 { + for index in 2.. 2 { + for index in 2..= Self.backoffMinimumCount else { continue } + shortContinuations[context, default: [:]][ids[index], default: 0] += 1 + } + } + + self.vocabulary = vocabulary + self.displayForms = displayForms + self.continuations = continuations + self.shortContinuations = shortContinuations + } + + /// The exact text to insert after `precedingText`, or nil when history is not confident. + /// + /// When the caret is inside a word ("Best reg"), the typed letters filter the first word and + /// only its untyped remainder is returned ("ards, Senad"). Otherwise the result starts with + /// the separating space. + func continuation(after precedingText: String, limits: Limits) -> String? { + let endsAtBoundary = precedingText.last.map { $0.isWhitespace } ?? true + var tokens = Self.tokens(in: String(precedingText.suffix(400))) + let partial = endsAtBoundary ? nil : tokens.popLast() + guard tokens.count >= 2 else { return nil } + + // The two words nearest the caret must be known; the third may be new (it only selects the + // three-word table), in which case the two-word fallback answers. + var ids: [Int32] = tokens.count >= Self.contextLength ? [] : [Self.unknownToken] + for (offset, token) in tokens.suffix(Self.contextLength).enumerated() { + let isNearCaret = offset >= min(tokens.count, Self.contextLength) - 2 + if let id = vocabulary[token.lowercased()] { + ids.append(id) + } else if isNearCaret { + return nil + } else { + ids.append(Self.unknownToken) + } + } + + var output = "" + var words = 0 + var weakestCount = Int32.max + var isFirstStep = true + while words < limits.maxWords { + let prefixFilter = isFirstStep ? partial?.lowercased() : nil + let context = Context(first: ids[ids.count - 3], second: ids[ids.count - 2], third: ids[ids.count - 1]) + let shortContext = ShortContext(first: ids[ids.count - 2], second: ids[ids.count - 1]) + let choice: (Int32, Int32)? + if let candidates = continuations[context] { + choice = Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefixFilter, + minimumCount: Self.minimumCount, minimumShare: Self.minimumShare + ) + } else if let candidates = shortContinuations[shortContext] { + choice = Self.confidentCandidate( + in: candidates, displayForms: displayForms, prefix: prefixFilter, + minimumCount: Self.backoffMinimumCount, minimumShare: Self.backoffMinimumShare + ) + } else { + choice = nil + } + guard let (nextID, count) = choice else { break } + let token = displayForms[Int(nextID)] + + if token == Self.newlineToken { + guard limits.allowsNewlines, !isFirstStep || partial == nil else { break } + output += "\n" + } else if isFirstStep, let partial { + output += String(token.dropFirst(partial.count)) + words += 1 + } else { + // A space separates words, except right after a line break or at the very start + // when the field already ends in whitespace the user typed. + let separator = output.hasSuffix("\n") || (output.isEmpty && endsAtBoundary) ? "" : " " + output += separator + token + words += 1 + } + weakestCount = min(weakestCount, count) + ids.append(nextID) + isFirstStep = false + if token.last.map({ ".!?".contains($0) }) == true { break } + } + + let visible = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !visible.isEmpty else { return nil } + if words < 2, weakestCount < Self.minimumSingleWordCount { return nil } + return output.hasSuffix("\n") ? String(output.dropLast()) : output + } + + private static func confidentCandidate( + in candidates: [Int32: Int32], + displayForms: [String], + prefix: String?, + minimumCount: Int32, + minimumShare: Double + ) -> (Int32, Int32)? { + let eligible = prefix.map { prefix in + candidates.filter { id, _ in + let form = displayForms[Int(id)].lowercased() + return form != newlineToken && form.hasPrefix(prefix) + } + } ?? candidates + let total = eligible.values.reduce(0, +) + guard let best = eligible.max(by: { $0.value < $1.value }), total > 0, + best.value >= minimumCount, Double(best.value) / Double(total) >= minimumShare + else { return nil } + return (best.key, best.value) + } + + /// Splits on spaces and tabs, keeping punctuation attached ("regards,") and turning each line + /// break into its own token so sign-offs that span lines can be learned. + static func tokens(in text: String) -> [String] { + var tokens: [String] = [] + var current = "" + for character in text { + if character.isNewline { + if !current.isEmpty { tokens.append(current); current = "" } + if tokens.last != newlineToken { tokens.append(newlineToken) } + } else if character.isWhitespace { + if !current.isEmpty { tokens.append(current); current = "" } + } else { + current.append(character) + } + } + if !current.isEmpty { tokens.append(current) } + return tokens + } +} diff --git a/Cotabby/Support/History/TypingHistoryScrubber.swift b/Cotabby/Support/History/TypingHistoryScrubber.swift new file mode 100644 index 00000000..1be77b78 --- /dev/null +++ b/Cotabby/Support/History/TypingHistoryScrubber.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Removes secret-like strings from text before it enters typing history. +/// +/// History is stored for months and fed back into prompts, so a pasted API key or private key must +/// not survive into it, even though the archive is encrypted and only on-device engines read it. +/// The rules are deliberately conservative about prose: ordinary words, names, and short numbers +/// pass through untouched, and only shapes that are almost never written by hand are replaced. +nonisolated enum TypingHistoryScrubber { + static let redaction = "[redacted]" + + /// Records longer than this keep only their tail. History is used for the user's recent wording, + /// and a 50k-character document would otherwise dominate retrieval and memory. + static let maximumRecordCharacters = 12_000 + + private static let privateKeyBlock = try? NSRegularExpression( + pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)" + ) + /// Known credential prefixes followed by a run of key characters (OpenAI/Anthropic `sk-`, + /// GitHub `ghp_`/`gho_`/`github_pat_`, Slack `xox?-`, AWS access key ids). + private static let prefixedCredential = try? NSRegularExpression( + pattern: "\\b(sk-[A-Za-z0-9_\\-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}" + + "|xox[abprs]-[A-Za-z0-9\\-]{10,}|AKIA[0-9A-Z]{16})" + ) + /// Any long unbroken token mixing letters and digits: JWTs, hex digests, base64 secrets. 24 + /// characters is longer than nearly every real word or identifier a person types. + private static let longMixedToken = try? NSRegularExpression( + pattern: "[A-Za-z0-9_\\-+/=.]{24,}" + ) + + /// Scrubs the text before and after the caret separately and caps the pair, so the boundary + /// between what the user typed and what was already there survives (`TypingHistoryRecord.typedLength`). + /// When the field is too long, the typed side keeps its end (nearest the caret) and the rest + /// keeps its start, the same "around the caret" window a reader would care about. + static func scrub(before: String, after: String) -> (text: String, typedLength: Int) { + var typed = scrub(before) + var rest = scrub(after) + let afterBudget = min(rest.count, maximumRecordCharacters / 6) + if typed.count + rest.count > maximumRecordCharacters { + rest = String(rest.prefix(afterBudget)) + typed = String(typed.suffix(maximumRecordCharacters - rest.count)) + } + while typed.first?.isWhitespace == true { typed.removeFirst() } + while rest.last?.isWhitespace == true { rest.removeLast() } + if rest.isEmpty { + while typed.last?.isWhitespace == true { typed.removeLast() } + } + return (typed + rest, typed.count) + } + + static func scrub(_ text: String) -> String { + var result = text + for expression in [privateKeyBlock, prefixedCredential].compactMap({ $0 }) { + result = replace(expression, in: result) + } + if let longMixedToken { + result = replaceMatches(of: longMixedToken, in: result) { token in + // Long all-letter runs are real words in agglutinative languages (Turkish, German + // compounds); only runs carrying digits look like generated secrets. + token.contains(where: \.isNumber) && token.contains(where: \.isLetter) ? redaction : token + } + } + if result.count > maximumRecordCharacters { + result = String(result.suffix(maximumRecordCharacters)) + } + return result + } + + private static func replace(_ expression: NSRegularExpression, in text: String) -> String { + let range = NSRange(text.startIndex..., in: text) + return expression.stringByReplacingMatches(in: text, range: range, withTemplate: redaction) + } + + private static func replaceMatches( + of expression: NSRegularExpression, + in text: String, + transform: (String) -> String + ) -> String { + let nsText = text as NSString + var output = "" + var cursor = 0 + for match in expression.matches(in: text, range: NSRange(location: 0, length: nsText.length)) { + output += nsText.substring(with: NSRange(location: cursor, length: match.range.location - cursor)) + output += transform(nsText.substring(with: match.range)) + cursor = match.range.location + match.range.length + } + output += nsText.substring(from: cursor) + return output + } +} diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 12c3ec7c..3b63faa1 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -30,6 +30,7 @@ enum BaseCompletionPromptRenderer { clipboardContext: String? = nil, visualContextSummary: String? = nil, surfaceContext: SurfaceContext? = nil, + historyExamples: [String] = [], usesCompactSurfaceContext: Bool = false, contextBudget: Int = defaultContextBudget, maxScreenCharacters: Int = 4000, @@ -62,6 +63,9 @@ enum BaseCompletionPromptRenderer { // the whole blob lands. sections.append(Self.contextSection("notes", "Notes the writer keeps in mind: \(notes)", priority: 40, maxChars: 1300)) } + if let history = Self.historySection(historyExamples) { + sections.append(history) + } if let clip = Self.nonEmpty(clipboardContext) { sections.append(Self.contextSection("clipboard", "On the clipboard: \(clip)", priority: 35, maxChars: 400)) } @@ -140,6 +144,23 @@ enum BaseCompletionPromptRenderer { return contextSection("surface", lines.joined(separator: " "), priority: 70, maxChars: 240) } + /// The user's own earlier sentences, quoted. A base model conditions strongly on nearby text in + /// the same voice, so a couple of real examples pull its word choice toward how this person + /// writes. Sits after the stable preface and before the per-keystroke clipboard and screen + /// sections: the examples change only every few words (`TypingHistoryQuery.stableText`), so + /// placing them earlier keeps more of the prompt's head reusable from the KV cache. + private static func historySection(_ examples: [String]) -> PromptSection? { + let quoted = examples + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { "“\($0)”" } + guard !quoted.isEmpty else { return nil } + return contextSection( + "history", "Earlier writing by the same author:\n" + quoted.joined(separator: "\n"), + priority: 38, maxChars: 760 + ) + } + private static func contextSection( _ name: String, _ content: String, diff --git a/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift b/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift index fda49121..b2c428f1 100644 --- a/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift +++ b/Cotabby/Support/Prompting/FoundationModelPromptRenderer.swift @@ -137,6 +137,16 @@ enum FoundationModelPromptRenderer { sections.append(summary) } + // The user's own earlier sentences live in the per-request prompt, not the instructions: + // they change as the topic moves, and instructions are the cached part of Apple's session. + // The framing says what they are for so the chat-tuned model borrows wording, not content. + let examples = request.historyExamples.filter { !$0.isEmpty } + if !examples.isEmpty { + sections.append("") + sections.append("Earlier writing by the same user, to match their wording (do not repeat it):") + sections.append(contentsOf: examples.map { "\"\($0)\"" }) + } + if let clipboardContext = request.clipboardContext, !clipboardContext.isEmpty { sections.append("") diff --git a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift index 8958f071..1a083d79 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift @@ -45,7 +45,8 @@ enum SuggestionRequestFactory { settings: SuggestionSettingsSnapshot, configuration: SuggestionConfiguration, clipboardContext: String? = nil, - visualContextSummary: String? = nil + visualContextSummary: String? = nil, + historyExamples: [String] = [] ) -> SuggestionRequestBuildResult { let prefixText = truncatedPromptPrefix( from: context.precedingText, @@ -93,6 +94,10 @@ enum SuggestionRequestFactory { fieldPlaceholder: context.fieldPlaceholder ) : nil + // Typing history stays on this Mac. The provider already returns nothing for the endpoint + // engine; dropping it here as well keeps that guarantee in the one pure place every request + // passes through. + let activeHistoryExamples = settings.selectedEngine == .openAICompatible ? [] : historyExamples // Cotabby 2 is a base-model continuation product on the Open Source path, so the local // prompt is always the base render: no instruction blob, exact caret prefix last. // Custom instructions and persona condition the output rather than being obeyed. The @@ -113,6 +118,7 @@ enum SuggestionRequestFactory { clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, surfaceContext: surfaceContext, + historyExamples: activeHistoryExamples, contextBudget: settings.selectedEngine == .openAICompatible ? 2400 : BaseCompletionPromptRenderer.defaultContextBudget, maxScreenCharacters: settings.selectedEngine == .openAICompatible ? 500 : 4000, screenPriority: settings.selectedEngine == .openAICompatible ? 30 : 45, @@ -145,6 +151,7 @@ enum SuggestionRequestFactory { clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, surfaceContext: surfaceContext, + historyExamples: activeHistoryExamples, isMultiLineEnabled: settings.isMultiLineEnabled, requestID: RequestID.generate(), wordRange: settings.effectiveWordRange diff --git a/Cotabby/UI/Settings/Panes/ContextPaneView.swift b/Cotabby/UI/Settings/Panes/ContextPaneView.swift index bf5dbcaf..5d677175 100644 --- a/Cotabby/UI/Settings/Panes/ContextPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ContextPaneView.swift @@ -25,6 +25,8 @@ import SwiftUI /// can type a trailing space; `SuggestionRequestFactory` does the once-per-request trim instead. struct ContextPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel + /// Owned by `CotabbyAppEnvironment`; drives the Typing History section. + @ObservedObject var typingHistory: TypingHistoryStore private static let previewEditorMinHeight: CGFloat = 132 private static let extendedContextEditorMinHeight: CGFloat = 220 @@ -34,6 +36,7 @@ struct ContextPaneView: View { livePreviewSection extendedContextSection howThisIsUsedSection + TypingHistorySectionView(store: typingHistory) } } diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift new file mode 100644 index 00000000..d51db69f --- /dev/null +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -0,0 +1,118 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The "Typing History" section of the Context settings pane. +/// +/// Presentation only: every action goes to `TypingHistoryStore`, which owns the preferences, +/// archive, and recording. A separate view keeps the Context pane readable and puts the privacy +/// wording for this feature in one place. +struct TypingHistorySectionView: View { + @ObservedObject var store: TypingHistoryStore + @State private var isConfirmingDeleteAll = false + + var body: some View { + Section("Typing History") { + Toggle(isOn: Binding(get: { store.preferences.isUsingHistory }, set: { store.setUsingHistory($0) })) { + SettingsRowLabel( + title: "Use My Typing History", + description: "Finishes phrases you often type and shows the model examples of how you " + + "write. Only Apple Intelligence and the local model use it; it is never sent to an endpoint.", + systemImage: "clock.arrow.circlepath" + ) + } + .settingsItem(.typingHistory) + + Toggle(isOn: Binding(get: { store.preferences.isRecording }, set: { store.setRecording($0) })) { + SettingsRowLabel( + title: "Record What I Type", + description: "Saves the text of fields where Cotabby is active, encrypted on this Mac. " + + "Password fields and disabled apps are never recorded.", + systemImage: "record.circle" + ) + } + .disabled(store.status != .ready) + + if !store.preferences.excludedBundleIdentifiers.isEmpty { + ForEach(store.preferences.excludedBundleIdentifiers, id: \.self) { identifier in + LabeledContent { + Button("Remove") { store.setExcluded(identifier, excluded: false) } + } label: { + Text("Not recorded: \(Self.displayName(for: identifier))") + } + } + } + + LabeledContent { + HStack(spacing: 8) { + Button("Exclude an App…") { chooseAppToExclude() } + Button("Import Cotypist Export…") { chooseExportToImport() } + .disabled(store.status != .ready || store.isImporting) + Button("Delete All…", role: .destructive) { isConfirmingDeleteAll = true } + .disabled(store.recordCount == 0) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entryCountLabel) + if let message = statusMessage { + Text(message) + .font(.caption) + .foregroundStyle(store.status == .ready ? Color.secondary : Color.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .confirmationDialog( + "Delete all typing history?", + isPresented: $isConfirmingDeleteAll + ) { + Button("Delete All \(store.recordCount) Entries", role: .destructive) { store.deleteAll() } + } message: { + Text("This removes every recorded and imported entry and its encryption key. It can't be undone.") + } + } + } + + private var entryCountLabel: String { + switch store.status { + case .loading: return "Opening typing history…" + case .ready, .unavailable: return store.recordCount == 1 ? "1 entry stored" : "\(store.recordCount) entries stored" + } + } + + private var statusMessage: String? { + if case let .unavailable(message) = store.status { return message } + if store.isImporting { return "Importing…" } + return store.lastImportMessage + } + + /// Asks for the `user_inputs.json` file from a decrypted Cotypist export. + private func chooseExportToImport() { + let panel = NSOpenPanel() + panel.title = "Import Cotypist Export" + panel.message = "Choose user_inputs.json from your Cotypist export." + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + Task { await store.importCotypistExport(from: url) } + } + + private func chooseAppToExclude() { + let panel = NSOpenPanel() + panel.title = "Don't Record in an App" + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url, + let identifier = Bundle(url: url)?.bundleIdentifier + else { return } + store.setExcluded(identifier, excluded: true) + } + + private static func displayName(for bundleIdentifier: String) -> String { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + return bundleIdentifier + } + return FileManager.default.displayName(atPath: url.path).replacingOccurrences(of: ".app", with: "") + } +} diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index e65917cc..89726b44 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -28,6 +28,8 @@ struct SettingsContainerView: View { let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void + /// Owned by `CotabbyAppEnvironment`; the Context pane observes it for the Typing History section. + let typingHistoryStore: TypingHistoryStore let onQuit: () -> Void @AppStorage("cotabbySettingsSelectedCategoryV2") @@ -143,7 +145,7 @@ struct SettingsContainerView: View { case .writing: WritingPaneView(suggestionSettings: suggestionSettings) case .context: - ContextPaneView(suggestionSettings: suggestionSettings) + ContextPaneView(suggestionSettings: suggestionSettings, typingHistory: typingHistoryStore) case .shortcuts: ShortcutsPaneView(suggestionSettings: suggestionSettings) case .apps: diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..72d228ad 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -59,6 +59,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case automaticallyFixTypos // Context case extendedContext + case typingHistory case contextLivePreview // Engine & Model case engine @@ -149,6 +150,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Spelling Dictionaries" case .automaticallyFixTypos: return "Automatically Fix Typos" case .extendedContext: return "Extended Context" + case .typingHistory: return "Typing History" case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" @@ -233,6 +235,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "character.book.closed" case .automaticallyFixTypos: return "checkmark.circle" case .extendedContext: return "doc.text" + case .typingHistory: return "clock.arrow.circlepath" case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" @@ -291,7 +294,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .length, .acceptPunctuation, .addSpaceAfterAccept, .name, .languages, .customRules, .hideSuggestionsOnTypo, .offerTypoCorrections, .spellingDictionaries, .automaticallyFixTypos: return .writing - case .extendedContext, .contextLivePreview: + case .extendedContext, .contextLivePreview, .typingHistory: return .context case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, @@ -360,6 +363,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Dictionaries used to detect typos." case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." case .extendedContext: return "A glossary or notes sent with every suggestion." + case .typingHistory: return "Learn from what you type, and import Cotypist history." case .contextLivePreview: return "A real field that exercises the full pipeline." case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." @@ -529,6 +533,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .extendedContext: return ["context", "glossary", "reference", "notes", "jargon", "instructions", "memory", "background", "system prompt", "vocabulary"] + case .typingHistory: + return ["history", "typing history", "personalize", "personalization", "learn", "record", + "cotypist", "import", "phrases", "my writing", "privacy", "delete"] case .contextLivePreview: return ["live", "preview", "test", "ghost", "try", "playground", "sandbox", "demo", "try it", "test field"] diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift new file mode 100644 index 00000000..60e104e5 --- /dev/null +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -0,0 +1,244 @@ +import CryptoKit +import XCTest +@testable import Cotabby + +/// Keeps vault keys in memory so tests never touch the developer's login Keychain. +private final class InMemoryKeyStore: TypingHistoryKeyStore, @unchecked Sendable { + private let lock = NSLock() + private var key: SymmetricKey? + + func existingKey() throws -> SymmetricKey? { lock.withLock { key } } + func createKey() throws -> SymmetricKey { + lock.withLock { + let created = SymmetricKey(size: .bits256) + key = created + return created + } + } + func deleteKey() throws { lock.withLock { key = nil } } +} + +@MainActor +final class TypingHistoryStoreTests: XCTestCase { + /// App-target MainActor classes crash the app-hosted runner when deallocated; keep them alive. + private static var retained: [AnyObject] = [] + + private var directory: URL! + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory.appendingPathComponent("typing-history-\(UUID().uuidString)") + suiteName = "cotabby.test.typingHistory.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + private func makeVault(keyStore: InMemoryKeyStore = InMemoryKeyStore()) -> TypingHistoryVault { + TypingHistoryVault(fileURL: directory.appendingPathComponent("TypingHistory.sealed"), keyStore: keyStore) + } + + private func makeStore(vault: TypingHistoryVault? = nil) -> TypingHistoryStore { + let store = TypingHistoryStore(vault: vault ?? makeVault(), userDefaults: defaults, loadsArchive: false) + Self.retained.append(store) + return store + } + + private func writeExport(_ rows: [[String: Any]]) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("user_inputs.json") + try JSONSerialization.data(withJSONObject: rows).write(to: url) + return url + } + + private func signOffRows(count: Int) -> [[String: Any]] { + (0.. FocusSnapshot { + let input = CotabbyTestFixtures.focusedInputSnapshot( + bundleIdentifier: app, elementIdentifier: element, precedingText: text, isSecure: isSecure + ) + return FocusSnapshot(applicationName: "Mail", bundleIdentifier: app, capability: .supported, context: input) + } + + func test_recordingCapturesAFieldOnceFocusMovesOn() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC")) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + XCTAssertEqual(store.recordCount, 0, "The field being typed in is not committed yet") + + store.observe(focus("", element: "other")) { true } + XCTAssertEqual(store.recordCount, 1) + } + + func test_recordingSkipsSecureFieldsExcludedAppsAndDisallowedContexts() { + let store = makeStore() + store.setRecording(true) + store.setExcluded("net.whatsapp.WhatsApp", excluded: true) + + store.observe(focus("correct horse battery staple password", isSecure: true)) { true } + store.observe(focus("", element: "x")) { true } + store.observe(focus("a long private chat message in WhatsApp", app: "net.whatsapp.WhatsApp")) { true } + store.observe(focus("", element: "y")) { true } + store.observe(focus("text typed while Cotabby is paused for now")) { false } + store.observe(focus("", element: "z")) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_recordingOffRecordsNothing() { + let store = makeStore() + + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + store.observe(focus("", element: "other")) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_aFieldThatBlinksUnsupportedResumesTheSameRecord() { + let store = makeStore() + store.setRecording(true) + let unsupported = FocusSnapshot(applicationName: "Mail", bundleIdentifier: "com.apple.mail", capability: .unsupported("blip"), context: nil) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready")) { true } + store.observe(unsupported) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.")) { true } + store.observe(focus("", element: "other")) { true } + + XCTAssertEqual(store.recordCount, 1) + } + + func test_deleteAllRemovesRecordsAndTheFile() async throws { + let vault = makeVault() + let store = makeStore(vault: vault) + await store.importCotypistExport(from: try writeExport(signOffRows(count: 3))) + store.flush() + XCTAssertTrue(FileManager.default.fileExists(atPath: vault.fileURL.path)) + + store.deleteAll() + + XCTAssertEqual(store.recordCount, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + } +} diff --git a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift index a5677310..20dcbcbd 100644 --- a/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift +++ b/CotabbyTests/Services/Runtime/SuggestionEngineRouterTests.swift @@ -114,6 +114,18 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { XCTAssertEqual(rig.metrics.entries.first?.latencyMs, 20) } + func test_endpointNeverReceivesARequestCarryingTypingHistory() async throws { + let rig = makeRig(engine: .openAICompatible) + + let result = try await rig.router.generateSuggestion( + for: CotabbyTestFixtures.suggestionRequest(historyExamples: ["My earlier sentence."]) + ) + + XCTAssertTrue(rig.endpoint.requests.isEmpty) + XCTAssertEqual(result.text, "") + XCTAssertEqual(result.suppressionReason, "historyWithheldFromEndpoint") + } + func test_llamaSelection_routesToLlamaEngineAndRecordsTheModelName() async throws { let rig = makeRig(engine: .llamaOpenSource) diff --git a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift new file mode 100644 index 00000000..f7cf56fb --- /dev/null +++ b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import Cotabby + +@MainActor +final class TypingHistoryPhraseEngineTests: XCTestCase { + private static var retained: [AnyObject] = [] + + private final class FixedHistory: SuggestionHistoryProviding { + var phrase: String? + var engines: [SuggestionEngineKind] = [] + + func historyExamples(for context: FocusedInputContext, engine: SuggestionEngineKind) -> [String] { [] } + func phraseContinuation(for request: SuggestionRequest, engine: SuggestionEngineKind) -> String? { + engines.append(engine) + return phrase + } + } + + private final class CountingEngine: SuggestionGenerating { + private(set) var calls = 0 + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + calls += 1 + return SuggestionResult(generation: request.generation, rawText: " model", text: " model", latency: 0.1) + } + func resetCachedGenerationContext() async {} + } + + private func makeEngine(phrase: String?, engine: SuggestionEngineKind = .appleIntelligence) + -> (TypingHistoryPhraseEngine, CountingEngine, FixedHistory) { + let base = CountingEngine() + let history = FixedHistory() + history.phrase = phrase + let wrapper = TypingHistoryPhraseEngine(wrapping: base, history: history, engineKind: { engine }) + Self.retained.append(contentsOf: [base, history, wrapper] as [AnyObject]) + return (wrapper, base, history) + } + + func test_confidentPhraseAnswersWithoutCallingTheModel() async throws { + let (engine, base, _) = makeEngine(phrase: " Senad") + + let result = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " Senad") + XCTAssertTrue(result.spacingIsExact) + XCTAssertEqual(base.calls, 0) + } + + func test_noPhraseFallsThroughToTheModel() async throws { + let (engine, base, _) = makeEngine(phrase: nil) + + let result = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " model") + XCTAssertEqual(base.calls, 1) + } + + func test_liveEngineKindIsPassedToHistory() async throws { + let (engine, _, history) = makeEngine(phrase: nil, engine: .openAICompatible) + + _ = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(history.engines, [.openAICompatible]) + } +} + +final class TypingHistoryPromptTests: XCTestCase { + func test_basePromptQuotesExamplesBeforeTheCaretText() { + let prompt = BaseCompletionPromptRenderer.prompt( + prefixText: "The POC is", + applicationName: "Mail", + userName: nil, + historyExamples: ["The Imperum POC starts Monday."] + ) + + XCTAssertTrue(prompt.contains("Earlier writing by the same author:\n“The Imperum POC starts Monday.”")) + XCTAssertTrue(prompt.hasSuffix("The POC is"), "The caret text must stay last") + } + + func test_basePromptWithoutExamplesIsUnchanged() { + XCTAssertEqual( + BaseCompletionPromptRenderer.prompt(prefixText: "The POC is", applicationName: "Mail", userName: nil), + BaseCompletionPromptRenderer.prompt( + prefixText: "The POC is", applicationName: "Mail", userName: nil, historyExamples: [] + ) + ) + } + + func test_foundationPromptCarriesExamples() { + let request = CotabbyTestFixtures.suggestionRequest(historyExamples: ["The Imperum POC starts Monday."]) + + let prompt = FoundationModelPromptRenderer.prompt(for: request) + + XCTAssertTrue(prompt.contains("\"The Imperum POC starts Monday.\"")) + XCTAssertFalse(FoundationModelPromptRenderer.sessionInstructions(for: request).contains("Imperum"), + "Examples stay out of the cached instructions") + } + + func test_factoryDropsExamplesForTheEndpointEngine() { + let context = CotabbyTestFixtures.focusedInputContext(precedingText: "The POC is") + let examples = ["The Imperum POC starts Monday."] + + let endpoint = SuggestionRequestFactory.buildRequest( + context: context, settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .openAICompatible), + configuration: .standard, historyExamples: examples + ).request + let local = SuggestionRequestFactory.buildRequest( + context: context, settings: CotabbyTestFixtures.settingsSnapshot(selectedEngine: .llamaOpenSource), + configuration: .standard, historyExamples: examples + ).request + + XCTAssertEqual(endpoint.historyExamples, []) + XCTAssertFalse(endpoint.prompt.contains("Imperum")) + XCTAssertEqual(local.historyExamples, examples) + XCTAssertTrue(local.prompt.contains("Imperum")) + } +} diff --git a/CotabbyTests/Support/History/CotypistExportImporterTests.swift b/CotabbyTests/Support/History/CotypistExportImporterTests.swift new file mode 100644 index 00000000..4923bcd7 --- /dev/null +++ b/CotabbyTests/Support/History/CotypistExportImporterTests.swift @@ -0,0 +1,50 @@ +@testable import Cotabby +import XCTest + +final class CotypistExportImporterTests: XCTestCase { + private func export(_ rows: [[String: Any]]) throws -> Data { + try JSONSerialization.data(withJSONObject: rows) + } + + func test_keepsOnlyTheMostCompleteSnapshotOfEachField() throws { + let data = try export([ + ["createdAt": "2026-06-05 16:23:53.028", "updatedAt": "2026-06-05 16:23:53.028", + "appBundleIdentifier": "com.microsoft.Outlook", "textUpToCursor": "Hi Arnaud, the POC is read", "domain": "-"], + ["createdAt": "2026-06-05 16:24:10.000", "updatedAt": "2026-06-05 16:24:10.000", + "appBundleIdentifier": "com.microsoft.Outlook", "textUpToCursor": "Hi Arnaud, the POC is ready for review.", + "textAfterCursor": " Kind regards, Senad", "domain": "-"], + ["createdAt": "2026-06-06 09:00:00.000", "appBundleIdentifier": "com.google.Chrome", + "textUpToCursor": "Can you summarize this incident report for me please?", "domain": "claude.ai"] + ]) + + let records = try CotypistExportImporter.records(fromExport: data) + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0].text, "Hi Arnaud, the POC is ready for review. Kind regards, Senad") + XCTAssertNil(records[0].domain, "Cotypist's \"-\" means no site") + XCTAssertEqual(records[1].domain, "claude.ai") + XCTAssertTrue(records.allSatisfy { $0.source == .imported }) + } + + func test_fragmentsAreDropped() throws { + let data = try export([["appBundleIdentifier": "net.whatsapp.WhatsApp", "textUpToCursor": "ok"]]) + XCTAssertEqual(try CotypistExportImporter.records(fromExport: data), []) + } + + func test_secretsAreScrubbedOnImport() throws { + let data = try export([[ + "appBundleIdentifier": "com.googlecode.iterm2", + "textUpToCursor": "export OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwx1234 && run the build" + ]]) + + let text = try CotypistExportImporter.records(fromExport: data).first?.text + + XCTAssertEqual(text?.contains("sk-proj"), false) + XCTAssertEqual(text?.contains("run the build"), true) + } + + func test_otherJSONIsRejected() { + XCTAssertThrowsError(try CotypistExportImporter.records(fromExport: Data("{\"a\":1}".utf8))) + XCTAssertThrowsError(try CotypistExportImporter.records(fromExport: Data("[{\"name\":\"x\"}]".utf8))) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryIndexTests.swift b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift new file mode 100644 index 00000000..fbaa7040 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryIndexTests.swift @@ -0,0 +1,75 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryIndexTests: XCTestCase { + private func record(_ text: String, app: String = "com.microsoft.Outlook", domain: String? = nil) -> TypingHistoryRecord { + TypingHistoryRecord( + id: UUID(), bundleIdentifier: app, domain: domain, + createdAt: Date(), updatedAt: Date(), text: text, source: .imported + ) + } + + private func query(_ text: String, app: String = "com.microsoft.Outlook", domain: String? = nil, field: String = "") -> TypingHistoryQuery { + TypingHistoryQuery(text: text, bundleIdentifier: app, domain: domain, currentFieldText: field) + } + + func test_returnsThePassageThatSharesTheMostInformativeWords() { + let index = TypingHistoryIndex(records: [ + record("The Imperum POC for the SOC team starts Monday. We will connect Microsoft Sentinel first."), + record("Dinner tonight at eight? I can book the Italian place near the station if you like."), + record("Our quarterly report covers revenue, hiring and the office move planned for spring.") + ]) + + let examples = index.examples(for: query("schedule the Imperum POC with Sentinel for the SOC")) + + XCTAssertEqual(examples.first?.contains("Sentinel"), true) + XCTAssertFalse(examples.contains { $0.contains("Dinner") }) + } + + func test_weakOverlapReturnsNothing() { + let index = TypingHistoryIndex(records: [ + record("The Imperum POC for the SOC team starts Monday. We will connect Microsoft Sentinel first.") + ]) + + XCTAssertEqual(index.examples(for: query("the team")), []) + } + + func test_anOlderSnapshotOfTheSameFieldIsSkipped() { + let draft = "Hi Arnaud, the Imperum POC with Sentinel is ready for the SOC team to review this week." + let index = TypingHistoryIndex(records: [record(draft)]) + + XCTAssertEqual(index.examples(for: query("Imperum POC Sentinel SOC review", field: draft + " Let me")), []) + } + + func test_passageIsBoundedAndKeepsWholeSentences() { + let long = "Short opener here. " + String(repeating: "Filler words about nothing in particular. ", count: 20) + + "The Imperum POC uses Sentinel connectors for SOC alerts. " + String(repeating: "More filler text follows. ", count: 20) + let index = TypingHistoryIndex(records: [record(long)]) + + let passage = index.examples(for: query("Imperum POC Sentinel connectors SOC alerts"), maxCharacters: 120).first + + XCTAssertNotNil(passage) + XCTAssertLessThanOrEqual(passage?.count ?? .max, 120) + XCTAssertEqual(passage?.contains("The Imperum POC uses Sentinel connectors for SOC alerts."), true) + } + + func test_sameSiteWinsATie() { + let index = TypingHistoryIndex(records: [ + record("Pipeline status for the Imperum connector build is green again today.", app: "com.google.Chrome", domain: "github.com"), + record("Pipeline status for the Imperum connector build is green again today!", app: "com.google.Chrome", domain: "claude.ai") + ]) + + let examples = index.examples(for: query("Imperum connector pipeline build status", app: "com.google.Chrome", domain: "claude.ai"), limit: 1) + + XCTAssertEqual(examples, ["Pipeline status for the Imperum connector build is green again today!"]) + } + + func test_stableTextOnlyChangesOnWholeWordBlocks() { + XCTAssertEqual(TypingHistoryQuery.stableText(from: "one two three", wordsPerBlock: 4), "") + XCTAssertEqual(TypingHistoryQuery.stableText(from: "a b c d e f", wordsPerBlock: 4), "a b c d") + XCTAssertEqual( + TypingHistoryQuery.stableText(from: "a b c d e f g", wordsPerBlock: 4), + TypingHistoryQuery.stableText(from: "a b c d e f", wordsPerBlock: 4) + ) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift b/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift new file mode 100644 index 00000000..59ced3c9 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryPhrasePredictorTests.swift @@ -0,0 +1,121 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryPhrasePredictorTests: XCTestCase { + private let limits = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: false) + + private func record(_ text: String) -> TypingHistoryRecord { + TypingHistoryRecord( + id: UUID(), bundleIdentifier: "com.apple.mail", domain: nil, + createdAt: Date(), updatedAt: Date(), text: text, source: .imported + ) + } + + private func predictor(_ texts: [String]) -> TypingHistoryPhrasePredictor { + TypingHistoryPhrasePredictor(records: texts.map(record)) + } + + func test_repeatedPhraseIsCompletedAfterAWordBoundary() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know ", limits: limits), + "if you have any questions." + ) + } + + func test_partialWordIsFinishedWithOnlyTheUntypedLetters() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know if you ha", limits: limits), + "ve any questions." + ) + } + + func test_fullyTypedWordWithoutSpaceGetsALeadingSpace() { + let history = Array(repeating: "Thanks again, please let me know if you have any questions.", count: 4) + + XCTAssertEqual( + predictor(history).continuation(after: "Sure, please let me know if", limits: limits), + " you have any questions." + ) + } + + func test_ambiguousContinuationIsNotOffered() { + let history = [ + "please let me know if it works", + "please let me know when it ships", + "please let me know what you think", + "please let me know how it goes" + ] + + XCTAssertNil(predictor(history).continuation(after: "please let me know ", limits: limits)) + } + + func test_tooFewOccurrencesAreNotOffered() { + let history = Array(repeating: "please let me know if you have any questions.", count: 2) + + XCTAssertNil(predictor(history).continuation(after: "please let me know ", limits: limits)) + } + + func test_singleWordShortcutNeedsStrongerEvidence() { + let three = Array(repeating: "Thanks for the update.\nBest regards, Senad\nImperum", count: 3) + let six = Array(repeating: "Thanks for the update.\nBest regards, Senad\nImperum", count: 6) + + XCTAssertNil(predictor(three).continuation(after: "Thanks!\nBest regards, ", limits: limits)) + XCTAssertEqual(predictor(six).continuation(after: "Thanks!\nBest regards, ", limits: limits), "Senad") + } + + func test_newlineContinuesOnlyWhenMultiLineIsAllowed() { + let history = Array(repeating: "Thanks for your time.\nKind regards,\nSenad Aruc\nImperum B.V.", count: 6) + let multiLine = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: true) + + XCTAssertNil(predictor(history).continuation(after: "Thanks for your time.\nKind regards,", limits: limits)) + XCTAssertEqual( + predictor(history).continuation(after: "Thanks for your time.\nKind regards,", limits: multiLine), + "\nSenad Aruc\nImperum B.V." + ) + } + + func test_outputUsesTheUsersSpellingAndRespectsTheWordLimit() { + let history = Array(repeating: "we will start the imperum POC with the SOC team next Monday morning", count: 4) + let twoWords = TypingHistoryPhrasePredictor.Limits(maxWords: 2, allowsNewlines: false) + + XCTAssertEqual(predictor(history).continuation(after: "Then we will start the ", limits: twoWords), "imperum POC") + } + + func test_twoWordFallbackAnswersWhenTheThirdWordIsNew() { + // "Kind regards," follows many different sentences, so the three-word context before it is + // rarely the same; the two-word fallback still knows what comes next. + let history = (0..<6).map { "Topic number \($0) is done.\nKind regards,\nSenad" } + let multiLine = TypingHistoryPhrasePredictor.Limits(maxWords: 12, allowsNewlines: true) + + XCTAssertEqual( + predictor(history).continuation(after: "Something never seen before.\nKind regards,", limits: multiLine), + "\nSenad" + ) + } + + func test_onlyTextTypedBeforeTheCaretIsLearned() { + // The quoted thread after the caret is someone else's writing; their name must not become + // the user's sign-off. + let records = (0..<6).map { _ -> TypingHistoryRecord in + let typed = "Thanks for the update.\nBest regards, Senad" + let quoted = "\n\nOn Monday Luuk wrote:\nThanks for the update.\nBest regards, Luuk" + var record = record(typed + quoted) + record.typedLength = typed.count + return record + } + + XCTAssertEqual( + TypingHistoryPhrasePredictor(records: records).continuation(after: "Done.\nBest regards, ", limits: limits), + "Senad" + ) + } + + func test_unknownContextReturnsNil() { + XCTAssertNil(predictor(["hello there general kenobi"]).continuation(after: "completely new words ", limits: limits)) + XCTAssertNil(predictor([]).continuation(after: "", limits: limits)) + } +} diff --git a/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift new file mode 100644 index 00000000..4bdf94c1 --- /dev/null +++ b/CotabbyTests/Support/History/TypingHistoryScrubberTests.swift @@ -0,0 +1,38 @@ +@testable import Cotabby +import XCTest + +final class TypingHistoryScrubberTests: XCTestCase { + func test_proseAndShortNumbersPassThrough() { + let text = "Hi Arnaud, the POC starts on 12 October at 10:30. Kind regards, Senad" + XCTAssertEqual(TypingHistoryScrubber.scrub(text), text) + } + + func test_longAllLetterWordsAreKept() { + let turkish = "Çekoslovakyalılaştıramadıklarımızdanmışsınız diye yazdım." + XCTAssertEqual(TypingHistoryScrubber.scrub(turkish), turkish) + } + + func test_credentialsAndTokensAreRedacted() { + let text = "key sk-ant-api03-abcdefghijklmnop1234 and ghp_abcdefghijklmnopqrstuvwxyz123456 " + + "plus token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123" + let scrubbed = TypingHistoryScrubber.scrub(text) + + XCTAssertFalse(scrubbed.contains("sk-ant")) + XCTAssertFalse(scrubbed.contains("ghp_")) + XCTAssertFalse(scrubbed.contains("eyJhbGci")) + XCTAssertTrue(scrubbed.hasPrefix("key [redacted] and [redacted]")) + } + + func test_privateKeyBlocksAreRedacted() { + let text = "here:\n-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n-----END OPENSSH PRIVATE KEY-----\nthanks" + XCTAssertEqual(TypingHistoryScrubber.scrub(text), "here:\n[redacted]\nthanks") + } + + func test_longRecordsKeepTheirTail() { + let text = String(repeating: "word ", count: 5_000) + "the end" + let scrubbed = TypingHistoryScrubber.scrub(text) + + XCTAssertEqual(scrubbed.count, TypingHistoryScrubber.maximumRecordCharacters) + XCTAssertTrue(scrubbed.hasSuffix("the end")) + } +} diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 25dd9819..105cf90c 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -126,6 +126,7 @@ enum CotabbyTestFixtures { languageInstruction: String? = nil, clipboardContext: String? = nil, visualContextSummary: String? = nil, + historyExamples: [String] = [], isMultiLineEnabled: Bool = false ) -> SuggestionRequest { let resolvedPrecedingText = precedingText ?? prefixText @@ -155,6 +156,7 @@ enum CotabbyTestFixtures { languageInstruction: languageInstruction, clipboardContext: clipboardContext, visualContextSummary: visualContextSummary, + historyExamples: historyExamples, isMultiLineEnabled: isMultiLineEnabled ) } diff --git a/SOURCE_LAYOUT.md b/SOURCE_LAYOUT.md index 5ff2b576..3c6778a3 100644 --- a/SOURCE_LAYOUT.md +++ b/SOURCE_LAYOUT.md @@ -35,6 +35,7 @@ Cotabby/ │ ├── Context/ bounded context and visual-context values │ ├── Emoji/ picker and usage values │ ├── Focus/ focus snapshots and tracking state +│ ├── History/ typing-history records, archive, and preferences │ ├── Input/ keyboard event values │ ├── Onboarding/ onboarding templates │ ├── Permissions/ TCC permission values @@ -52,6 +53,7 @@ Cotabby/ │ │ ├── Caching/ field-scoped focus caches │ │ ├── Chromium/ Chromium AX enablement and diagnostics │ │ └── Resolution/ focus snapshots, geometry, bounded AX walks +│ ├── History/ typing-history store, recording, and encrypted vault │ ├── Input/ event taps and input-source monitoring │ ├── ModelManagement/ model discovery, download, and validation │ ├── Permission/ @@ -77,6 +79,7 @@ Cotabby/ │ ├── Focus/ │ │ ├── Applications/ app, browser, domain, and terminal classification │ │ └── Capability/ supported-field capability resolution +│ ├── History/ history retrieval, phrase prediction, scrubbing, import │ ├── Input/ composition, key-label, and selection helpers │ ├── Logging/ debug options, request IDs, and JSONL handlers │ ├── Macros/ From e86b259d5b957cea3d3b86e2511965e1f666174b Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 14:03:33 +0200 Subject: [PATCH 2/3] Add per-app App Settings: completions, mid-line, autocorrect, keys, instructions, history The Apps pane becomes a list of every app Cotabby knows about, most-used first (typing-history input counts), with a detail screen per app: - Enable completions (writes the existing disabled-apps list) - Mid-line completions: Default (on) / On / Off. Off skips new requests while the caret's line has text after it; visible tails still follow typing. - Autocorrect: Default / On / Off over the typo gate. Off disables the whole gate; On enables hiding and offering, keeping the global auto-fix choice. - Accept Word / Accept Entire Suggestion keys (the existing per-app overrides, moved into PerAppShortcutRowsView), incl. Disable for Tab. - Instructions for this app, joined to Extended Context so both engines render them (global custom rules are not sent to the model). - Typing history: collect on/off (store exclusions), input count, and per-app Delete. New behavior lives on the existing per-app override record (PerAppBehavior, optional so old records decode) and reaches the pipeline through SuggestionSettingsSnapshot.perAppBehaviors. PerAppSettingsResolver is the single place that applies "default follows global". TypingHistoryStore gains recordCountsByApp and deleteRecords(forBundleIdentifier:). Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 46 ++ .../SuggestionCoordinator+Prediction.swift | 26 +- Cotabby/Models/Settings/PerAppBehavior.swift | 42 ++ .../Settings/PerAppShortcutOverride.swift | 8 +- .../Settings/SuggestionSettingsModel.swift | 41 +- .../Suggestion/SuggestionEngineModels.swift | 3 + .../Services/History/TypingHistoryStore.swift | 30 +- .../Support/Settings/AppSettingsList.swift | 75 +++ .../Settings/PerAppSettingsResolver.swift | 59 ++ .../Settings/SuggestionSettingsStore.swift | 10 +- .../Request/SuggestionRequestFactory.swift | 19 +- .../Panes/Apps/AppSettingsDetailView.swift | 236 ++++++++ .../Panes/Apps/PerAppShortcutRowsView.swift | 137 +++++ Cotabby/UI/Settings/Panes/AppsPaneView.swift | 553 +++++------------- .../UI/Settings/SettingsContainerView.swift | 2 +- Cotabby/UI/Settings/SettingsIndex.swift | 8 +- .../SuggestionCoordinatorPerAppTests.swift | 45 ++ .../SuggestionSettingsModelTests.swift | 30 + .../History/TypingHistoryStoreTests.swift | 15 + .../Settings/PerAppSettingsTests.swift | 118 ++++ .../TestSupport/CotabbyTestFixtures.swift | 6 +- 21 files changed, 1073 insertions(+), 436 deletions(-) create mode 100644 Cotabby/Models/Settings/PerAppBehavior.swift create mode 100644 Cotabby/Support/Settings/AppSettingsList.swift create mode 100644 Cotabby/Support/Settings/PerAppSettingsResolver.swift create mode 100644 Cotabby/UI/Settings/Panes/Apps/AppSettingsDetailView.swift create mode 100644 Cotabby/UI/Settings/Panes/Apps/PerAppShortcutRowsView.swift create mode 100644 CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPerAppTests.swift create mode 100644 CotabbyTests/Support/Settings/PerAppSettingsTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index c17ed32b..bdbd5a34 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ 24A641C63FB5DB47C2116A5C /* OpenAICompatibleEndpointConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40BA54CDA6F5887BEC6C668 /* OpenAICompatibleEndpointConfigurationTests.swift */; }; 24D5BE34BE7B7B72A93C6A9F /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */; }; 24E0F4F0EC9148FB409FD112 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; + 251B7A491488BD44BE9FD5F7 /* AppSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272052CDEFA45031BE0507CE /* AppSettingsDetailView.swift */; }; 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; @@ -176,6 +177,7 @@ 29D8E4E0F4D4ADF4136E20AE /* EmojiPopularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DB52780A112A686C06D7E3 /* EmojiPopularity.swift */; }; 29E20FF0F413E723D2A526C4 /* DecodeStopPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C319B4FF652512A69377DD5C /* DecodeStopPolicy.swift */; }; 2A3FC700422694CAF70C5449 /* SuggestionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8543EBA09E11A5066880CC91 /* SuggestionResult.swift */; }; + 2A60F949459F4FB6357A7202 /* PerAppSettingsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A0240056E678E31444517A /* PerAppSettingsResolver.swift */; }; 2AA1A89BD542ADFCE16F594C /* SuggestionAnchorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4861CC2533F3EA222A0833A9 /* SuggestionAnchorCache.swift */; }; 2AF12199E0FD9B802BF35A10 /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D41CC179EC04093971A0D0 /* SpellingDictionaryCatalog.swift */; }; 2B355C61C20866199F414EB9 /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */; }; @@ -272,9 +274,11 @@ 4109287495CF3436709E94BC /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0ACD725A16F26E9F29124E4 /* SuggestionPauseModels.swift */; }; 4115E3AB3417BFA8DE43E6FD /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308F8660F70ED64511BDB59F /* RandomMacroEvaluator.swift */; }; 4134ADBE464D00BB748BD9AE /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; + 4174B57914FE95760213DB3A /* SuggestionCoordinatorPerAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A948E67F48787167A9264AAA /* SuggestionCoordinatorPerAppTests.swift */; }; 41924323814E45BFEDAA5359 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 41ACF0EDFE4E69328E4EA292 /* Aria2Locator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29EA4138BBF767BA8D27223 /* Aria2Locator.swift */; }; 41F36D158005A69CE87921FA /* PermissionOverlayTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD003D4EBE530DC0E2B87C24 /* PermissionOverlayTrackerTests.swift */; }; + 42541E5E03F5EDB10331426A /* PerAppSettingsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A0240056E678E31444517A /* PerAppSettingsResolver.swift */; }; 430EB30535A955C3904C2AD9 /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F49C55FFF4352A6763BAE1A /* PerDomainDisableSettings.swift */; }; 44265797C6EC7798A5E61EF3 /* DateMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A26D86030FC5FA67371C52 /* DateMacroEvaluator.swift */; }; 442999B123DFBAD112231987 /* SuggestionLengthPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA2EA95C425C3EAF246C66D /* SuggestionLengthPolicy.swift */; }; @@ -290,6 +294,7 @@ 4744AC9FCAA7856BF70D5A37 /* SuggestionStreamingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157C86303F9B51B6F648E5B3 /* SuggestionStreamingState.swift */; }; 475259B5A87A3FA07475136F /* KeybindRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6D44A9B4EC51505A0A363 /* KeybindRow.swift */; }; 477488B0FDAD0A9B76DA9848 /* OpenAICompatibleAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA17364BAFFA56E7DDF682D /* OpenAICompatibleAPIClientTests.swift */; }; + 47A7A03D7D5904E46A7C54C1 /* PerAppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBA4F78D9433B8BA49B5937 /* PerAppSettingsTests.swift */; }; 47D099DE9608A174BB0C3C3F /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771EAA74003DAD7D4F3FD199 /* OnboardingTemplateFeatureListTests.swift */; }; 47EFFC4AEBA2848C933BB77C /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */; }; 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; @@ -343,6 +348,7 @@ 556197D64680237CBCCCDD9F /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53ED6F2F3354FB3E6065804 /* WelcomeTemplateStepView.swift */; }; 5619C761998CCBB3FD50CBDB /* TypingHistoryScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87625EB90EB00670718FB790 /* TypingHistoryScrubberTests.swift */; }; + 563B8093BBFA7EBDCCBAD161 /* AppSettingsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0376234C87BC8996F16124E /* AppSettingsList.swift */; }; 56471188E5F78EE0785D7BF7 /* CotabbyInference-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2472C76E96009300EBF038D4 /* CotabbyInference-MIT.txt */; }; 564BA8F230F45E378D26B36C /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; 56987393D95962CD49B4A4E8 /* TypingHistoryPhraseEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB8BFC8B80A47088FDDFC24 /* TypingHistoryPhraseEngine.swift */; }; @@ -378,6 +384,7 @@ 5E506F0CD6FA82B323264C88 /* RandomMacroEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0DF8F53EDD401B6A9EE06E /* RandomMacroEvaluatorTests.swift */; }; 5EA37C462E8CF319B8748A40 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD93D7DE258C8E5367A433D /* EmojiCatalog.swift */; }; 5ED4272F22F0FB0DF2AE0AE1 /* MPL-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4E283DF8948B10268B46811F /* MPL-2.0.txt */; }; + 5EDF512A536A875556AC1DD1 /* PerAppShortcutRowsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F598FA0C434A387DE1D3627B /* PerAppShortcutRowsView.swift */; }; 5F1F159BF6E776F3A85F1442 /* CaretLagPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4963D7DB8B810CD0C4E8CF2 /* CaretLagPolicyTests.swift */; }; 5F2BFD0D7718CC774F03FBE0 /* FoundationModelAvailabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACB1195A78B524AB65D2EF4 /* FoundationModelAvailabilityServiceTests.swift */; }; 5F2F0475865445A777B5E3F0 /* CompletionSeamGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC0C7FE7FB134C225EA7B71 /* CompletionSeamGuardTests.swift */; }; @@ -433,6 +440,7 @@ 6BED0111C262E06BFF751BF4 /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */; }; 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F324C645A4E53FB6E90061A /* DownloadOutcomeClassifier.swift */; }; 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */; }; + 6C8A40C014B4742E53A0B204 /* PerAppBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0935B25C7AD8ABB88EBED9F1 /* PerAppBehavior.swift */; }; 6CB0A928230ACD262B02CFDF /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */; }; 6CB435B203CBDD1F63B93332 /* PerAppShortcutOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */; }; 6CCFAA1D86C4E4DC7E3DB8A3 /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5652FFF8E718A17D98F87CD0 /* SuggestionSubsystemContracts.swift */; }; @@ -470,6 +478,7 @@ 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74C07F4568C4FAE1C344E789 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD4DE5D9D8C9BB66B78633 /* TextDirectionDetectorTests.swift */; }; 74EE4B6CFAD07099867BD0FC /* SpellingLanguageResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71686BC29B7E0FACFF0B19C /* SpellingLanguageResolverTests.swift */; }; + 74F9291A0EE3D40EB6A3A7A4 /* PerAppShortcutRowsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F598FA0C434A387DE1D3627B /* PerAppShortcutRowsView.swift */; }; 7527A1523BFCCC1D418B44FD /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADE6EEA8248DA6700E11844 /* MenuBarPopoverDismisser.swift */; }; 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 75432BB598E96902A799AA10 /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B23950F38B1B3C8A5CC5BE5 /* OpenAICompatibleCredentialStore.swift */; }; @@ -792,6 +801,7 @@ C7492A81906AD943E286F4B5 /* SuggestionContinuationPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 790082EFCDEF94454A3B805F /* SuggestionContinuationPlanTests.swift */; }; C7A48F93691943EF2EEBD775 /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3AD6D4125ED99669F4EC32 /* DownloadOutcomeClassifierTests.swift */; }; C82AD8930B5C3EB06D51A75A /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD834BCFF812935889E16D5A /* SuggestionRequestFactory.swift */; }; + C830D585A92D226E48AE731A /* AppSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 272052CDEFA45031BE0507CE /* AppSettingsDetailView.swift */; }; C8434622BFCE324E7044E581 /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB42779285A1BA40B7848C8 /* TickMarkSlider.swift */; }; C8682DF05F0B3C3950FB64B1 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B966EA885CB107161FC3006 /* SystemMetricsStore.swift */; }; C8A3B724BB54A1209583B78B /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF8F1A40EAA82773C07F8B9 /* VisualContextStartCoalescer.swift */; }; @@ -859,6 +869,7 @@ D795377F4F24F49740808960 /* SuggestionInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6017E8AEDF88042933C61DFD /* SuggestionInteractionState.swift */; }; D79C2A5DCDAFB9741E208103 /* WordCountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E22DCC1852B8E4B235D256 /* WordCountFormatter.swift */; }; D7BE35CFCBD105D06786E3F2 /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F000304686E4573BC351AD3F /* PermissionDragSourceView.swift */; }; + D7C15AB3BE090187A5D7A2CD /* AppSettingsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0376234C87BC8996F16124E /* AppSettingsList.swift */; }; D80D98F6C7B86AAF8C831865 /* he-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7C9BB65FA5FC42B89766B037 /* he-100k.txt */; }; D81AC2294A64E7A95B29E081 /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; D8B0D41FED19138D1869BB3C /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE72A73D906285AE10F623B /* EmojiRecents.swift */; }; @@ -868,6 +879,7 @@ D9CE1B135CA6FB766426BD05 /* SuggestionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E23EF696D34CFB8EF320668 /* SuggestionRequest.swift */; }; D9CE5079C27826E94A29C744 /* HostAdvanceFitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A236731DA3FFF8FEECB67167 /* HostAdvanceFitTests.swift */; }; DA00477BB00EB3E95F204CB2 /* EmojiUsageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846A1C3328B3B1CB42E2E46B /* EmojiUsageStore.swift */; }; + DA2D2D72CF836FE7E363D879 /* PerAppBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0935B25C7AD8ABB88EBED9F1 /* PerAppBehavior.swift */; }; DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA02F079AD5EB20CDB215 /* TypefaceMatcher.swift */; }; DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */; }; DA8D967DFA3AFDE469D1FAE7 /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630D1F95FD08BC30E624672 /* DebouncePolicy.swift */; }; @@ -1039,6 +1051,7 @@ 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; 081E05039D4CC2BE2EFCEAD4 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 0854896090B568FFB1926D24 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = ""; }; + 0935B25C7AD8ABB88EBED9F1 /* PerAppBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppBehavior.swift; sourceTree = ""; }; 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Endpoint.swift"; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; @@ -1093,6 +1106,7 @@ 1EE99DFA6D7D4B925AFAD1B4 /* OpenAICompatibleAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClient.swift; sourceTree = ""; }; 1F4A8BCFC1F9F09B10A4D6F1 /* SuggestionWordAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWordAcceptanceTests.swift; sourceTree = ""; }; 1FB0AF4FEC5A3FD07652F718 /* CotabbyDebugOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptionsTests.swift; sourceTree = ""; }; + 1FBA4F78D9433B8BA49B5937 /* PerAppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppSettingsTests.swift; sourceTree = ""; }; 1FD9262617968D8D1EF1EA45 /* GhostSpaceBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostSpaceBoundaryTests.swift; sourceTree = ""; }; 202086735E28B96BBCE55E6B /* LlamaEvalScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaEvalScoring.swift; sourceTree = ""; }; 209089420F2EE43C312657A7 /* SystemUIFocusShadowPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemUIFocusShadowPolicyTests.swift; sourceTree = ""; }; @@ -1115,6 +1129,7 @@ 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 264C3F355F2F82D1964DD263 /* EmojiQueryRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRun.swift; sourceTree = ""; }; 266C8BF167118855C56FC7F4 /* BrowserAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAppDetector.swift; sourceTree = ""; }; + 272052CDEFA45031BE0507CE /* AppSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsDetailView.swift; sourceTree = ""; }; 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextComposer.swift; sourceTree = ""; }; 29163A3D0BB0F11EEAC2985A /* GhostTextPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextPanelView.swift; sourceTree = ""; }; 2B23950F38B1B3C8A5CC5BE5 /* OpenAICompatibleCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleCredentialStore.swift; sourceTree = ""; }; @@ -1382,6 +1397,7 @@ 92854D0927EBF17A48AE2804 /* LowPowerModeMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowPowerModeMonitorTests.swift; sourceTree = ""; }; 928A884292355FEF8B605B64 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifier.swift; sourceTree = ""; }; + 93A0240056E678E31444517A /* PerAppSettingsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppSettingsResolver.swift; sourceTree = ""; }; 94A26D86030FC5FA67371C52 /* DateMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluator.swift; sourceTree = ""; }; 94B45E958CF9BE63A728E273 /* TypefaceEvidenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypefaceEvidenceTests.swift; sourceTree = ""; }; 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRankerTests.swift; sourceTree = ""; }; @@ -1436,6 +1452,7 @@ A914A6C81B1824E4907ACADE /* SuggestionSettingsData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsData.swift; sourceTree = ""; }; A9331B12568E717FCF9A5086 /* CurrentWordExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordExtractorTests.swift; sourceTree = ""; }; A936CA937FA629F828938516 /* LlamaTypingSessionEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaTypingSessionEvalTests.swift; sourceTree = ""; }; + A948E67F48787167A9264AAA /* SuggestionCoordinatorPerAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorPerAppTests.swift; sourceTree = ""; }; A9805B5D25A6E261D92E0ADD /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizer.swift; sourceTree = ""; }; AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; @@ -1537,6 +1554,7 @@ CF96999230079A768BEFD0FC /* KeycapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeycapView.swift; sourceTree = ""; }; CFB06531A54FDCDBAAD14E8B /* FocusedInputContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputContext.swift; sourceTree = ""; }; CFB3F1AA4007F0B44CA70E6F /* SymSpellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellTests.swift; sourceTree = ""; }; + D0376234C87BC8996F16124E /* AppSettingsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsList.swift; sourceTree = ""; }; D07B9AB842C16672AC490EFB /* FocusCapabilityResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolverTests.swift; sourceTree = ""; }; D0DB1F528070F8F9108720DB /* SettingsSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchResultRow.swift; sourceTree = ""; }; D1123AB515110BD0CBA39490 /* HomePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePaneView.swift; sourceTree = ""; }; @@ -1636,6 +1654,7 @@ F3D991D3536ED989C89AF44D /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; F47CD9DF50E7192A70D1870E /* CotabbyDebugOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptions.swift; sourceTree = ""; }; F4B98B1ABB36E600D7ECA185 /* SpeculativeAcceptanceContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeculativeAcceptanceContextTests.swift; sourceTree = ""; }; + F598FA0C434A387DE1D3627B /* PerAppShortcutRowsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutRowsView.swift; sourceTree = ""; }; F6809474A7C1B57F1D13AA6F /* SuggestionDismissalMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDismissalMemoryTests.swift; sourceTree = ""; }; F7014E6E969FD4579028BAC4 /* TypingCadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingCadence.swift; sourceTree = ""; }; F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; @@ -1786,6 +1805,7 @@ 5830E18C10728B7AF1DB2CCF /* SuggestionCoordinatorContinuityTests.swift */, 694D2DD5826C379BA4A6C775 /* SuggestionCoordinatorInputTests.swift */, 25C4859A89B59938DB905377 /* SuggestionCoordinatorLifecycleTests.swift */, + A948E67F48787167A9264AAA /* SuggestionCoordinatorPerAppTests.swift */, EAA290A7B1BF37F3D125DC8F /* SuggestionCoordinatorPredictionTests.swift */, 2471F967A05EB4C8BF7CD242 /* SuggestionCoordinatorRigTestCase.swift */, 61B6924817244D5A909B9624 /* SuggestionCoordinatorTypingPredictionTests.swift */, @@ -1929,7 +1949,9 @@ 22548CAEF237D0D8CFCBD171 /* Settings */ = { isa = PBXGroup; children = ( + D0376234C87BC8996F16124E /* AppSettingsList.swift */, 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */, + 93A0240056E678E31444517A /* PerAppSettingsResolver.swift */, 6F49C55FFF4352A6763BAE1A /* PerDomainDisableSettings.swift */, 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */, 57B29DAC600626783649475A /* SettingsSearchRanker.swift */, @@ -2013,6 +2035,7 @@ isa = PBXGroup; children = ( 86932C7E8F10E5F324EFCEE5 /* About */, + 630173F53C3E6E1C7E30C55A /* Apps */, 7029E66A0AC7AD6C8B999F24 /* Engine */, AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */, D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */, @@ -2059,6 +2082,7 @@ isa = PBXGroup; children = ( 66B5FF2D1DCC846AABC134CF /* MenuBarRecoveryPolicyTests.swift */, + 1FBA4F78D9433B8BA49B5937 /* PerAppSettingsTests.swift */, 9227DE16CA5CE2E085C1A891 /* PerAppShortcutOverrideStoreTests.swift */, 6E53B16C1E0CE667E9666C71 /* SettingsAttentionEvaluatorTests.swift */, 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */, @@ -2253,6 +2277,7 @@ isa = PBXGroup; children = ( 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */, + 0935B25C7AD8ABB88EBED9F1 /* PerAppBehavior.swift */, 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */, A914A6C81B1824E4907ACADE /* SuggestionSettingsData.swift */, DFFEDED24AED136A9C83F730 /* SuggestionSettingsModel.swift */, @@ -2370,6 +2395,15 @@ path = Presentation; sourceTree = ""; }; + 630173F53C3E6E1C7E30C55A /* Apps */ = { + isa = PBXGroup; + children = ( + 272052CDEFA45031BE0507CE /* AppSettingsDetailView.swift */, + F598FA0C434A387DE1D3627B /* PerAppShortcutRowsView.swift */, + ); + path = Apps; + sourceTree = ""; + }; 643C6D3D4745CED7E4A6417B /* Spelling */ = { isa = PBXGroup; children = ( @@ -3942,6 +3976,8 @@ 56D2FDE72FE21C35E8BDD06C /* ActivationIndicatorController.swift in Sources */, CC54CAD96A348364989132D3 /* ActiveSuggestionSession.swift in Sources */, 9EB8E3DC796A0C8BFDE8E683 /* AppDelegate.swift in Sources */, + C830D585A92D226E48AE731A /* AppSettingsDetailView.swift in Sources */, + D7C15AB3BE090187A5D7A2CD /* AppSettingsList.swift in Sources */, C5805F78041265CA1836249B /* AppSurfaceClassifier.swift in Sources */, 4E8268A5C44B9E5945EBE59D /* AppUpdateManager.swift in Sources */, B4D36F5D03E3143CE74582F9 /* AppearancePaneView.swift in Sources */, @@ -4123,7 +4159,10 @@ BBC7F32FC99DBC41B0102E54 /* OpenAICompatibleEndpointModels.swift in Sources */, 888FF8B27F8545F1EE9BBA93 /* OpenAICompatibleSuggestionEngine.swift in Sources */, 9261E4F7F4671A6D9ED8B0D4 /* OverlayController.swift in Sources */, + DA2D2D72CF836FE7E363D879 /* PerAppBehavior.swift in Sources */, + 42541E5E03F5EDB10331426A /* PerAppSettingsResolver.swift in Sources */, 6CB435B203CBDD1F63B93332 /* PerAppShortcutOverride.swift in Sources */, + 5EDF512A536A875556AC1DD1 /* PerAppShortcutRowsView.swift in Sources */, 5E1CE9C9062D2DEB4B96FD5C /* PerDomainDisableSettings.swift in Sources */, F5E397207AFEB2AA97C264A0 /* PerformanceMetricsStore.swift in Sources */, ACC1D8C50007AA193271F977 /* PerformancePaneView.swift in Sources */, @@ -4278,6 +4317,8 @@ 72672FBC57236EF484340659 /* ActivationIndicatorController.swift in Sources */, 102E1EDC04636AFB607A8249 /* ActiveSuggestionSession.swift in Sources */, 0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */, + 251B7A491488BD44BE9FD5F7 /* AppSettingsDetailView.swift in Sources */, + 563B8093BBFA7EBDCCBAD161 /* AppSettingsList.swift in Sources */, B393275E40EA9EB901E20820 /* AppSurfaceClassifier.swift in Sources */, 7955ED6ECA506D4EDA204BB1 /* AppUpdateManager.swift in Sources */, AB5D37BA744546F14C5566E8 /* AppearancePaneView.swift in Sources */, @@ -4459,7 +4500,10 @@ FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */, E2E8DACB17C65B8273BB624C /* OpenAICompatibleSuggestionEngine.swift in Sources */, A7F8B6C13DD0BA5304BCDFD2 /* OverlayController.swift in Sources */, + 6C8A40C014B4742E53A0B204 /* PerAppBehavior.swift in Sources */, + 2A60F949459F4FB6357A7202 /* PerAppSettingsResolver.swift in Sources */, 96117B3DC1292771EE6B8316 /* PerAppShortcutOverride.swift in Sources */, + 74F9291A0EE3D40EB6A3A7A4 /* PerAppShortcutRowsView.swift in Sources */, 430EB30535A955C3904C2AD9 /* PerDomainDisableSettings.swift in Sources */, 0BC6F3ED46B28033B80355BF /* PerformanceMetricsStore.swift in Sources */, 60636D92D12FED132250D8D2 /* PerformancePaneView.swift in Sources */, @@ -4752,6 +4796,7 @@ 24A641C63FB5DB47C2116A5C /* OpenAICompatibleEndpointConfigurationTests.swift in Sources */, FCEE05402A708C33F9719D7F /* OpenAICompatibleSuggestionEngineTests.swift in Sources */, F0A97143C565D2C2BD2339DF /* OverlayControllerTypefaceTests.swift in Sources */, + 47A7A03D7D5904E46A7C54C1 /* PerAppSettingsTests.swift in Sources */, E96817EE505817F9E616B31C /* PerAppShortcutOverrideStoreTests.swift in Sources */, 599A5F23125EE32592AA180E /* PerAppShortcutOverrideTests.swift in Sources */, 8B36A3FDDBB803C72A1E521B /* PerDomainDisableSettingsTests.swift in Sources */, @@ -4802,6 +4847,7 @@ B6C2570DB8D17C8626D50F76 /* SuggestionCoordinatorContinuityTests.swift in Sources */, 1DBFE5862FEFD38793600B01 /* SuggestionCoordinatorInputTests.swift in Sources */, 03175AAC8AA2789681B18DFB /* SuggestionCoordinatorLifecycleTests.swift in Sources */, + 4174B57914FE95760213DB3A /* SuggestionCoordinatorPerAppTests.swift in Sources */, 0A87210653A6C00EA62DC005 /* SuggestionCoordinatorPredictionTests.swift in Sources */, 89B0199B226B7B6FCEBBBAC3 /* SuggestionCoordinatorRigTestCase.swift in Sources */, 1972708E96F0643D5E434D6F /* SuggestionCoordinatorTestSupport.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 3853fd63..da306d24 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -163,7 +163,10 @@ extension SuggestionCoordinator { guard SuggestionRequestFactory.shouldGenerateSuggestion( for: rawContext.precedingText, trailingText: rawContext.trailingText, - suggestWithinWords: settingsSnapshot.suggestWithinWords + suggestWithinWords: settingsSnapshot.suggestWithinWords, + allowsMidLine: PerAppSettingsResolver.allowsMidLineCompletions( + bundleIdentifier: rawContext.bundleIdentifier, settings: settingsSnapshot + ) ) else { // A visual-context refresh may ask for new work while a valid tail is visible. // The boundary preference quiets new suggestions without interrupting type-through. @@ -263,11 +266,16 @@ extension SuggestionCoordinator { // gate with its correction semantics; declining here only skips the speculation. guard SuggestionRequestFactory.shouldGenerateSuggestion( for: optimistic.precedingText, trailingText: optimistic.trailingText, - suggestWithinWords: settingsSnapshot.suggestWithinWords + suggestWithinWords: settingsSnapshot.suggestWithinWords, + allowsMidLine: PerAppSettingsResolver.allowsMidLineCompletions( + bundleIdentifier: optimistic.bundleIdentifier, settings: settingsSnapshot + ) ) else { return } - if settingsSnapshot.suppressCompletionsOnTypo, + if PerAppSettingsResolver.typoSettings( + bundleIdentifier: optimistic.bundleIdentifier, settings: settingsSnapshot + ).suppressCompletionsOnTypo, let trailingWord = CaretWordContext.committedWord(in: optimistic.precedingText)?.word, spellChecker.isTypo(trailingWord) { return @@ -347,7 +355,10 @@ extension SuggestionCoordinator { after: rawContext, precedingText: session.precedingTextOnceTypedThrough ) guard SuggestionRequestFactory.shouldGenerateSuggestion( - for: optimistic.precedingText, trailingText: optimistic.trailingText + for: optimistic.precedingText, trailingText: optimistic.trailingText, + allowsMidLine: PerAppSettingsResolver.allowsMidLineCompletions( + bundleIdentifier: optimistic.bundleIdentifier, settings: settingsSnapshot + ) ) else { return } hasPrefetchedContinuation = true @@ -585,10 +596,9 @@ extension SuggestionCoordinator { private func handleTypoGate(rawContext: FocusedInputSnapshot, workID: UInt64) -> Bool { switch TypoGate.resolve( precedingText: rawContext.precedingText, - settings: TypoGate.Settings( - suppressCompletionsOnTypo: settingsSnapshot.suppressCompletionsOnTypo, - offerTypoCorrections: settingsSnapshot.offerTypoCorrections, - automaticallyFixTypos: settingsSnapshot.automaticallyFixTypos + // Autocorrect can be turned on or off per app; the resolver applies that over the globals. + settings: PerAppSettingsResolver.typoSettings( + bundleIdentifier: rawContext.bundleIdentifier, settings: settingsSnapshot ), isTypo: { spellChecker.isTypo($0) }, bestCorrection: { diff --git a/Cotabby/Models/Settings/PerAppBehavior.swift b/Cotabby/Models/Settings/PerAppBehavior.swift new file mode 100644 index 00000000..d7fd8e62 --- /dev/null +++ b/Cotabby/Models/Settings/PerAppBehavior.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A per-app choice that can follow the global setting or force it on or off. Mirrors the +/// "Default (on) / On / Off" pickers users know from the Apps pane. +nonisolated enum PerAppToggle: String, Codable, CaseIterable, Equatable, Sendable { + case useDefault + case on + case off + + /// Applies the choice over the global value. + func resolve(default globalValue: Bool) -> Bool { + switch self { + case .useDefault: return globalValue + case .on: return true + case .off: return false + } + } +} + +/// App-specific suggestion behavior stored on that app's `PerAppShortcutOverride` record. +/// +/// Why it lives there: Cotabby already keeps one persisted, published record per configured app for +/// its accept keys. Putting behavior on the same record keeps every per-app choice in one place, so +/// removing an app from the Apps pane removes all of its overrides together, and old saved records +/// (without this field) still decode. Completions on/off is not here because the disabled-apps list +/// already owns it; typing-history collection is not here because `TypingHistoryStore` owns it. +nonisolated struct PerAppBehavior: Codable, Equatable, Sendable { + /// Same cap as global Extended Context: these notes join it in the same prompt section. + static let maximumInstructionCharacters = 1_200 + + /// Whether new suggestions may start while text follows the caret on the same line. + var midLineCompletions: PerAppToggle = .useDefault + /// Whether the typo gate (hide completions on a typo, offer and auto-apply corrections) runs. + var autocorrect: PerAppToggle = .useDefault + /// Notes added to Extended Context only while typing in this app. + var instructions: String = "" + + var isEmpty: Bool { + midLineCompletions == .useDefault && autocorrect == .useDefault + && instructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Cotabby/Models/Settings/PerAppShortcutOverride.swift b/Cotabby/Models/Settings/PerAppShortcutOverride.swift index d0b99e0b..f06613a2 100644 --- a/Cotabby/Models/Settings/PerAppShortcutOverride.swift +++ b/Cotabby/Models/Settings/PerAppShortcutOverride.swift @@ -1,12 +1,16 @@ import Foundation -/// Shortcut choices for one app. A `nil` action inherits its global binding; the disabled-key -/// sentinel remains an explicit override. +/// Per-app overrides for one app: accept keys and suggestion behavior. A `nil` action inherits its +/// global binding; the disabled-key sentinel remains an explicit override. The type keeps its +/// original name because its JSON is persisted under `cotabbyPerAppShortcutOverrides`. struct PerAppShortcutOverride: Codable, Equatable, Identifiable, Sendable { let bundleIdentifier: String var displayName: String var acceptance: SuggestionShortcutBindingSettings? var fullAcceptance: SuggestionShortcutBindingSettings? + /// App-specific suggestion behavior (mid-line, autocorrect, instructions). Optional so records + /// saved before this existed decode unchanged; nil means every behavior follows the global setting. + var behavior: PerAppBehavior? var id: String { bundleIdentifier } } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 61644320..31856fe5 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -521,10 +521,19 @@ final class SuggestionSettingsModel: ObservableObject { suppressCompletionsOnTypo: settings.correction.suppressCompletionsOnTypo, offerTypoCorrections: settings.correction.offerTypoCorrections, enabledSpellingDictionaryCodes: settings.correction.enabledSpellingDictionaryCodes, - automaticallyFixTypos: settings.correction.automaticallyFixTypos + automaticallyFixTypos: settings.correction.automaticallyFixTypos, + perAppBehaviors: Self.behaviorMap(settings.shortcuts.perAppOverrides) ) } + /// The per-app behavior the suggestion pipeline reads, keyed by bundle identifier. Apps whose + /// record only overrides keys are left out, so the map stays small and equality cheap. + static func behaviorMap(_ overrides: [PerAppShortcutOverride]) -> [String: PerAppBehavior] { + Dictionary(uniqueKeysWithValues: overrides.compactMap { override in + override.behavior.map { (override.bundleIdentifier, $0) } + }) + } + func selectEngine(_ engine: SuggestionEngineKind) { guard selectedEngine != engine else { return @@ -1447,6 +1456,27 @@ final class SuggestionSettingsModel: ObservableObject { upsertPerAppOverride(override) } + /// Edits one app's suggestion behavior. The record is created on first use, like a per-app key. + func updatePerAppBehavior( + bundleIdentifier: String, + displayName: String, + _ change: (inout PerAppBehavior) -> Void + ) { + guard var override = perAppOverrideForMutation( + bundleIdentifier: bundleIdentifier, + displayName: displayName + ) else { return } + var behavior = override.behavior ?? PerAppBehavior() + change(&behavior) + behavior = SuggestionSettingsStore.normalizedPerAppBehavior(behavior) + override.behavior = behavior.isEmpty ? nil : behavior + upsertPerAppOverride(override) + } + + func perAppBehavior(forBundleIdentifier bundleIdentifier: String) -> PerAppBehavior { + existingPerAppOverride(bundleIdentifier: bundleIdentifier)?.behavior ?? PerAppBehavior() + } + func removePerAppOverride(bundleIdentifier: String) { guard let normalizedBundleIdentifier = SuggestionSettingsStore.normalizedBundleIdentifier(bundleIdentifier) else { return @@ -1655,7 +1685,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { // The outer `CombineLatest4` is full, so these settings share its grouped publisher slot. return Publishers.CombineLatest4( primary, - $acceptanceGranularity, + // Per-app behavior shares the granularity slot; both change only from the Settings window. + Publishers.CombineLatest($acceptanceGranularity, $perAppShortcutOverrides), Publishers.CombineLatest4( $extendedContext, $suggestInIntegratedTerminals, @@ -1664,7 +1695,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { ), customRange ) - .map { primaryTuple, granularity, extendedContextTuple, customRangeTuple in + .map { primaryTuple, granularityAndOverrides, extendedContextTuple, customRangeTuple in + let (granularity, perAppOverrides) = granularityAndOverrides let (combinedSettings, presentationToggles, profile, timing) = primaryTuple let (globalState, disabledAppRules, engine, wordCountPreset) = combinedSettings let (globallyEnabled, pauseState) = globalState @@ -1708,7 +1740,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { suppressCompletionsOnTypo: suppressOnTypo, offerTypoCorrections: offerCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + perAppBehaviors: Self.behaviorMap(perAppOverrides) ) } .removeDuplicates() diff --git a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift index 4ccfd538..3af65f05 100644 --- a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift +++ b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift @@ -186,6 +186,9 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// commits the misspelled word with Space. The word boundary prevents pauses in unfinished words /// from triggering destructive edits. let automaticallyFixTypos: Bool + /// App-specific behavior by bundle identifier (mid-line, autocorrect, instructions). Read through + /// `PerAppSettingsResolver`, never directly, so the "default follows global" rule lives in one place. + let perAppBehaviors: [String: PerAppBehavior] /// Single chokepoint that picks between the preset's range and the user's custom range. /// Every downstream consumer (token-budget math, prompt-instruction text, UI labels in the diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index f4940938..09359e1e 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -28,6 +28,8 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { @Published private(set) var preferences: TypingHistoryPreferences @Published private(set) var recordCount = 0 + /// Entries per app, for the Apps settings list and its per-app Delete. + @Published private(set) var recordCountsByApp: [String: Int] = [:] @Published private(set) var status: Status = .loading @Published private(set) var isImporting = false @Published private(set) var lastImportMessage: String? @@ -126,7 +128,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { do { let loaded = try await Task.detached(priority: .utility) { try vault.load() }.value records = loaded - recordCount = loaded.count + refreshCounts() status = .ready rebuildSearchStructures() } catch { @@ -255,7 +257,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { // The user cleared the field down to nothing worth keeping. if let existingIndex { records.remove(at: existingIndex) - recordCount = records.count + refreshCounts() return true } return false @@ -275,7 +277,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { )) trimToCapacity() } - recordCount = records.count + refreshCounts() return true } @@ -301,7 +303,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { let fresh = imported.filter { !knownTexts.contains($0.text) } records.append(contentsOf: fresh) trimToCapacity() - recordCount = records.count + refreshCounts() lastImportMessage = "Imported \(fresh.count) entries" + (imported.count > fresh.count ? " (\(imported.count - fresh.count) were already in your history)." : ".") scheduleSave() @@ -311,13 +313,31 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { } } + /// Removes one app's records, keeping everything else. The field being typed in that app is + /// dropped too, so it is not written back a few seconds later. + func deleteRecords(forBundleIdentifier bundleIdentifier: String) { + if activeRecording?.bundleIdentifier == bundleIdentifier { activeRecording = nil } + if lastFinishedRecording?.bundleIdentifier == bundleIdentifier { lastFinishedRecording = nil } + let remaining = records.filter { $0.bundleIdentifier != bundleIdentifier } + guard remaining.count != records.count else { return } + records = remaining + refreshCounts() + scheduleSave() + rebuildSearchStructures() + } + + private func refreshCounts() { + recordCount = records.count + recordCountsByApp = Dictionary(grouping: records, by: \.bundleIdentifier).mapValues(\.count) + } + /// Removes every record, the encrypted file, and its Keychain key. func deleteAll() { saveTask?.cancel() activeRecording = nil lastFinishedRecording = nil records = [] - recordCount = 0 + refreshCounts() index = nil phrases = nil exampleCache = nil diff --git a/Cotabby/Support/Settings/AppSettingsList.swift b/Cotabby/Support/Settings/AppSettingsList.swift new file mode 100644 index 00000000..f382e4a9 --- /dev/null +++ b/Cotabby/Support/Settings/AppSettingsList.swift @@ -0,0 +1,75 @@ +import Foundation + +/// One row of the Apps settings list. +struct AppSettingsEntry: Equatable, Identifiable { + let bundleIdentifier: String + let displayName: String + /// Typing-history entries collected in this app. + let inputCount: Int + let isDisabled: Bool + /// The app has its own keys or behavior instead of following every global setting. + let hasOverrides: Bool + let isExcludedFromHistory: Bool + + var id: String { bundleIdentifier } +} + +/// Builds the Apps settings list from the stores that each own part of an app's configuration. +/// +/// An app is listed when anything is known about it: typing history was collected there, it is +/// disabled, it has per-app overrides, it is excluded from history, or the user just added it. +/// Pure so the merge and ordering rules are tested without any AppKit lookups; display names for +/// apps only known by bundle identifier come from the injected `displayName` closure. +enum AppSettingsList { + static func entries( + inputCounts: [String: Int], + disabledRules: [DisabledApplicationRule], + overrides: [PerAppShortcutOverride], + excludedFromHistory: [String], + addedBundleIdentifiers: [String] = [], + displayName: (String) -> String + ) -> [AppSettingsEntry] { + var names: [String: String] = [:] + for rule in disabledRules { names[rule.bundleIdentifier] = rule.displayName } + for override in overrides { names[override.bundleIdentifier] = override.displayName } + + let disabled = Set(disabledRules.map { $0.bundleIdentifier }) + let customizedOverrides = overrides.filter { override in + override.acceptance != nil || override.fullAcceptance != nil || override.behavior != nil + } + let customized = Set(customizedOverrides.map { $0.bundleIdentifier }) + let excluded = Set(excludedFromHistory) + var identifiers = Set(inputCounts.keys) + identifiers.formUnion(disabled) + identifiers.formUnion(overrides.map { $0.bundleIdentifier }) + identifiers.formUnion(excluded) + identifiers.formUnion(addedBundleIdentifiers) + // Imported rows with no app recorded carry this placeholder; there is no app to configure. + identifiers.remove("unknown") + + return identifiers.map { identifier in + AppSettingsEntry( + bundleIdentifier: identifier, + displayName: names[identifier] ?? displayName(identifier), + inputCount: inputCounts[identifier] ?? 0, + isDisabled: disabled.contains(identifier), + hasOverrides: customized.contains(identifier), + isExcludedFromHistory: excluded.contains(identifier) + ) + } + .sorted { + // Most-used first, like the list users know from other autocomplete apps; ties by name. + if $0.inputCount != $1.inputCount { return $0.inputCount > $1.inputCount } + return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + /// Case-insensitive match on name or bundle identifier for the list's search field. + static func filter(_ entries: [AppSettingsEntry], query: String) -> [AppSettingsEntry] { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return entries } + return entries.filter { + $0.displayName.localizedCaseInsensitiveContains(trimmed) || $0.bundleIdentifier.localizedCaseInsensitiveContains(trimmed) + } + } +} diff --git a/Cotabby/Support/Settings/PerAppSettingsResolver.swift b/Cotabby/Support/Settings/PerAppSettingsResolver.swift new file mode 100644 index 00000000..fdab98ef --- /dev/null +++ b/Cotabby/Support/Settings/PerAppSettingsResolver.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Resolves the effective suggestion behavior for the app being typed in. +/// +/// Every per-app choice is "follow the global setting, or force on/off" (`PerAppToggle`). Keeping +/// that rule in one pure type, like `ShortcutResolver` does for accept keys, means the request +/// factory, the typo gate, and the generation gate can never disagree about what "Default" means. +enum PerAppSettingsResolver { + /// Whether a new suggestion may start while text follows the caret on its line. The global + /// default is on: Cotabby already shows those suggestions as a card under the caret. + static func allowsMidLineCompletions(bundleIdentifier: String?, settings: SuggestionSettingsSnapshot) -> Bool { + behavior(bundleIdentifier, settings)?.midLineCompletions.resolve(default: true) ?? true + } + + /// The typo gate's switches in this app. Turning autocorrect off disables all three, so a word + /// that looks misspelled neither hides the completion nor gets a correction. Turning it on + /// enables hiding and offering; automatic fixing stays the user's global choice because it + /// edits text without asking. + static func typoSettings(bundleIdentifier: String?, settings: SuggestionSettingsSnapshot) -> TypoGate.Settings { + let global = TypoGate.Settings( + suppressCompletionsOnTypo: settings.suppressCompletionsOnTypo, + offerTypoCorrections: settings.offerTypoCorrections, + automaticallyFixTypos: settings.automaticallyFixTypos + ) + switch behavior(bundleIdentifier, settings)?.autocorrect ?? .useDefault { + case .useDefault: + return global + case .on: + return TypoGate.Settings( + suppressCompletionsOnTypo: true, + offerTypoCorrections: true, + automaticallyFixTypos: settings.automaticallyFixTypos + ) + case .off: + return TypoGate.Settings(suppressCompletionsOnTypo: false, offerTypoCorrections: false, automaticallyFixTypos: false) + } + } + + /// Whether autocorrect is effectively on, for the "Default (on/off)" label in Settings. + static func globalAutocorrectIsOn(_ settings: SuggestionSettingsSnapshot) -> Bool { + settings.suppressCompletionsOnTypo && settings.offerTypoCorrections + } + + /// Global Extended Context followed by this app's instructions, or nil when both are empty. + /// They share one prompt section so both engines render app notes the same way global notes are + /// rendered today. + static func extendedContext(bundleIdentifier: String?, settings: SuggestionSettingsSnapshot) -> String? { + let global = settings.extendedContext.trimmingCharacters(in: .whitespacesAndNewlines) + let app = (behavior(bundleIdentifier, settings)?.instructions ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let combined = [global, app].filter { !$0.isEmpty }.joined(separator: "\n") + return combined.isEmpty ? nil : combined + } + + private static func behavior(_ bundleIdentifier: String?, _ settings: SuggestionSettingsSnapshot) -> PerAppBehavior? { + guard let bundleIdentifier else { return nil } + return settings.perAppBehaviors[bundleIdentifier] + } +} diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 2f724e93..ec25a1b3 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -1118,7 +1118,8 @@ struct SuggestionSettingsStore { fallbackBundleIdentifier: normalizedBundleIdentifier ), acceptance: override.acceptance, - fullAcceptance: override.fullAcceptance + fullAcceptance: override.fullAcceptance, + behavior: override.behavior.map(normalizedPerAppBehavior) ) } @@ -1127,6 +1128,13 @@ struct SuggestionSettingsStore { // MARK: - Pure value normalizers (shared with the facade's setters) + /// Caps per-app instructions at the same length as Extended Context. + nonisolated static func normalizedPerAppBehavior(_ behavior: PerAppBehavior) -> PerAppBehavior { + var normalized = behavior + normalized.instructions = String(behavior.instructions.prefix(PerAppBehavior.maximumInstructionCharacters)) + return normalized + } + static func sortedDisabledAppRules( _ rules: [DisabledApplicationRule] ) -> [DisabledApplicationRule] { diff --git a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift index 1a083d79..88671a23 100644 --- a/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift +++ b/Cotabby/Support/Suggestion/Request/SuggestionRequestFactory.swift @@ -32,13 +32,23 @@ enum SuggestionRequestFactory { /// The full pre-generation gate: some typed text (and, when the boundary preference asks for /// it, a finished word), and a caret that is not parked inside a token (see /// `CaretTokenPosition`), where any completion would duplicate or splice what follows. + /// + /// `allowsMidLine` is the per-app "mid-line completions" choice: when false, no new suggestion + /// starts while the caret's line has text after it. A suggestion already on screen still + /// follows typing; this only gates new requests, like `suggestWithinWords`. static func shouldGenerateSuggestion( - for precedingText: String, trailingText: String, suggestWithinWords: Bool = true + for precedingText: String, trailingText: String, suggestWithinWords: Bool = true, allowsMidLine: Bool = true ) -> Bool { guard shouldGenerateSuggestion(for: precedingText, suggestWithinWords: suggestWithinWords) else { return false } + if !allowsMidLine, hasTextLaterOnLine(trailingText) { return false } return !CaretTokenPosition.isInsideToken(precedingText: precedingText, trailingText: trailingText) } + /// True when non-whitespace text follows the caret before the next line break. + static func hasTextLaterOnLine(_ trailingText: String) -> Bool { + trailingText.prefix { !$0.isNewline }.contains { !$0.isWhitespace } + } + /// Builds the generation request plus the exact prompt preview used by Cotabby's diagnostics UI. static func buildRequest( context: FocusedInputContext, @@ -65,9 +75,10 @@ enum SuggestionRequestFactory { // would prevent the user from typing a space at the end of a word in the editor). Do the // trim here, once per request, and collapse a whitespace-only body back to nil so renderers // skip the section heading entirely. - let trimmedExtendedContext = settings.extendedContext - .trimmingCharacters(in: .whitespacesAndNewlines) - let activeExtendedContext = trimmedExtendedContext.isEmpty ? nil : trimmedExtendedContext + // Global notes plus this app's own instructions, already trimmed; nil when both are empty. + let activeExtendedContext = PerAppSettingsResolver.extendedContext( + bundleIdentifier: context.bundleIdentifier, settings: settings + ) // nil when the user declared no languages — the renderers then just match the surrounding text. let languageInstruction = LanguageCatalog.promptInstruction(for: settings.responseLanguages) let boundedClipboardContext = activeClipboardContext( diff --git a/Cotabby/UI/Settings/Panes/Apps/AppSettingsDetailView.swift b/Cotabby/UI/Settings/Panes/Apps/AppSettingsDetailView.swift new file mode 100644 index 00000000..9cc84b8f --- /dev/null +++ b/Cotabby/UI/Settings/Panes/Apps/AppSettingsDetailView.swift @@ -0,0 +1,236 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Everything Cotabby does differently in one app: completions on/off, mid-line completions, +/// autocorrect, accept keys, app instructions, and that app's typing history. +/// +/// Presentation only. Each control writes to the store that owns the concept, so there is no +/// second copy of any setting: the disabled-apps list (completions), the per-app override record +/// (keys and `PerAppBehavior`), and `TypingHistoryStore` (collection, count, deletion). +struct AppSettingsDetailView: View { + @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var typingHistory: TypingHistoryStore + let bundleIdentifier: String + let displayName: String + let onBack: () -> Void + + @State private var isConfirmingDelete = false + + var body: some View { + Section { + HStack(spacing: 12) { + Button(action: onBack) { + Label("All Apps", systemImage: "chevron.left") + } + .buttonStyle(.borderless) + Spacer(minLength: 0) + } + HStack(spacing: 12) { + Image(nsImage: AppIconCache.icon(for: bundleIdentifier)) + .resizable() + .frame(width: 40, height: 40) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(displayName).font(.title3.weight(.semibold)) + Text(bundleIdentifier).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + } + } + + Section("Completions") { + Picker(selection: completionsBinding) { + Text("Default (\(onOff(suggestionSettings.isGloballyEnabled)))").tag(PerAppToggle.useDefault) + Text("Off").tag(PerAppToggle.off) + } label: { + SettingsRowLabel( + title: "Enable Completions", + description: "Turn suggestions off in this app, for example one with its own autocomplete.", + systemImage: "text.cursor" + ) + } + + Picker(selection: behaviorBinding(\.midLineCompletions)) { + Text("Default (on)").tag(PerAppToggle.useDefault) + Text("On").tag(PerAppToggle.on) + Text("Off").tag(PerAppToggle.off) + } label: { + SettingsRowLabel( + title: "Mid-Line Completions", + description: "Suggest even when there is text after the cursor on the same line. " + + "Turn off if suggestions get in the way of existing text here.", + systemImage: "text.insert" + ) + } + + Picker(selection: behaviorBinding(\.autocorrect)) { + Text("Default (\(onOff(PerAppSettingsResolver.globalAutocorrectIsOn(suggestionSettings.snapshot))))") + .tag(PerAppToggle.useDefault) + Text("On").tag(PerAppToggle.on) + Text("Off").tag(PerAppToggle.off) + } label: { + SettingsRowLabel( + title: "Autocorrect", + description: "Offer a correction when the word you're typing looks misspelled. " + + "Covers only the current word.", + systemImage: "textformat.abc.dottedunderline" + ) + } + } + + Section("Keys") { + PerAppShortcutRowsView( + suggestionSettings: suggestionSettings, + bundleIdentifier: bundleIdentifier, + displayName: displayName + ) + } + + Section("Instructions for This App") { + VStack(alignment: .leading, spacing: 8) { + Text("Notes the model keeps in mind only in \(displayName): its language, tone, or terms. " + + "They are added to your Extended Context, so they reach the selected engine the same way.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + TextEditor(text: instructionsBinding) + .font(.system(size: 13)) + .frame(minHeight: 90) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityLabel("Instructions for \(displayName)") + Text("\(behavior.instructions.count) / \(PerAppBehavior.maximumInstructionCharacters) characters") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 4) + } + + Section("Typing History") { + Picker(selection: collectBinding) { + Text("Default (\(onOff(typingHistory.preferences.isRecording)))").tag(PerAppToggle.useDefault) + Text("Off").tag(PerAppToggle.off) + } label: { + SettingsRowLabel( + title: "Collect Inputs", + description: "Record what you type in this app so suggestions adapt to how you write. " + + "Encrypted and stored only on this Mac.", + systemImage: "clock.arrow.circlepath" + ) + } + + LabeledContent { + Button("Delete…", role: .destructive) { isConfirmingDelete = true } + .disabled(inputCount == 0) + } label: { + Text(inputCount == 1 ? "1 input collected in this app" : "\(inputCount) inputs collected in this app") + } + .confirmationDialog("Delete \(inputCount) inputs from \(displayName)?", isPresented: $isConfirmingDelete) { + Button("Delete", role: .destructive) { + typingHistory.deleteRecords(forBundleIdentifier: bundleIdentifier) + } + } message: { + Text("History from other apps is kept. This can't be undone.") + } + } + + Section { + Button("Reset \(displayName) to Defaults") { resetToDefaults() } + .help("Turn completions back on and remove this app's keys, behavior, and history exclusion.") + } + } + + // MARK: - Bindings + + private var behavior: PerAppBehavior { + suggestionSettings.perAppBehavior(forBundleIdentifier: bundleIdentifier) + } + + private var inputCount: Int { + typingHistory.recordCountsByApp[bundleIdentifier] ?? 0 + } + + private var completionsBinding: Binding { + Binding( + get: { suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleIdentifier) ? .off : .useDefault }, + set: { choice in + if choice == .off { + suggestionSettings.disableApplication(bundleIdentifier: bundleIdentifier, displayName: displayName) + } else { + suggestionSettings.removeDisabledApplication(bundleIdentifier: bundleIdentifier) + } + } + ) + } + + private func behaviorBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { behavior[keyPath: keyPath] }, + set: { value in + suggestionSettings.updatePerAppBehavior(bundleIdentifier: bundleIdentifier, displayName: displayName) { + $0[keyPath: keyPath] = value + } + } + ) + } + + private var instructionsBinding: Binding { + Binding( + get: { behavior.instructions }, + set: { text in + suggestionSettings.updatePerAppBehavior(bundleIdentifier: bundleIdentifier, displayName: displayName) { + $0.instructions = text + } + } + ) + } + + private var collectBinding: Binding { + Binding( + get: { typingHistory.preferences.excludedBundleIdentifiers.contains(bundleIdentifier) ? .off : .useDefault }, + set: { typingHistory.setExcluded(bundleIdentifier, excluded: $0 == .off) } + ) + } + + private func onOff(_ value: Bool) -> String { value ? "on" : "off" } + + private func resetToDefaults() { + suggestionSettings.removeDisabledApplication(bundleIdentifier: bundleIdentifier) + suggestionSettings.removePerAppOverride(bundleIdentifier: bundleIdentifier) + typingHistory.setExcluded(bundleIdentifier, excluded: false) + } +} + +/// App icons and names looked up once per bundle identifier. `NSWorkspace` lookups hit Launch +/// Services; caching keeps a long app list from repeating them on every redraw. +@MainActor +enum AppIconCache { + private static var icons: [String: NSImage] = [:] + private static var names: [String: String] = [:] + + static func icon(for bundleIdentifier: String) -> NSImage { + if let cached = icons[bundleIdentifier] { return cached } + let icon = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { NSWorkspace.shared.icon(forFile: $0.path) } + ?? NSWorkspace.shared.icon(for: .applicationBundle) + icons[bundleIdentifier] = icon + return icon + } + + static func displayName(for bundleIdentifier: String) -> String { + if let cached = names[bundleIdentifier] { return cached } + let name = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { FileManager.default.displayName(atPath: $0.path).replacingOccurrences(of: ".app", with: "") } + ?? bundleIdentifier + names[bundleIdentifier] = name + return name + } +} diff --git a/Cotabby/UI/Settings/Panes/Apps/PerAppShortcutRowsView.swift b/Cotabby/UI/Settings/Panes/Apps/PerAppShortcutRowsView.swift new file mode 100644 index 00000000..25b65d64 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/Apps/PerAppShortcutRowsView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +/// The Accept Word and Accept Entire Suggestion rows for one app: inherit the global key, record an +/// app-specific key, or disable the action there (for example, so Tab keeps its native job). +/// +/// Extracted from the old flat Apps pane so the per-app detail screen can show it. It owns the one +/// active key recorder, which keeps two recorders from listening at once. +struct PerAppShortcutRowsView: View { + @ObservedObject var suggestionSettings: SuggestionSettingsModel + let bundleIdentifier: String + let displayName: String + + @State private var recordingAction: PerAppShortcutAction? + + var body: some View { + row(.acceptWord, title: "Accept Word", globalLabel: suggestionSettings.acceptanceKeyLabel) + row(.acceptEntireSuggestion, title: "Accept Entire Suggestion", globalLabel: suggestionSettings.fullAcceptanceKeyLabel) + } + + private var override: PerAppShortcutOverride? { + suggestionSettings.perAppShortcutOverrides.first { $0.bundleIdentifier == bundleIdentifier } + } + + @ViewBuilder + private func row(_ action: PerAppShortcutAction, title: String, globalLabel: String) -> some View { + let binding = resolvedBinding(action) + let inherits = action == .acceptWord ? override?.acceptance == nil : override?.fullAcceptance == nil + let isRecording = Binding( + get: { recordingAction == action }, + set: { recordingAction = $0 ? action : (recordingAction == action ? nil : recordingAction) } + ) + + LabeledContent { + if inherits { + HStack(spacing: 8) { + if isRecording.wrappedValue { + KeyRecorderView( + onKeyRecorded: { keyCode, modifiers, label in + apply(action, keyCode: keyCode, modifiers: modifiers, label: label) + recordingAction = nil + }, + onCancelled: { recordingAction = nil }, + conflictChecker: conflictChecker(action) + ) + } else { + Text("Uses global (\(binding.label))") + .font(.caption) + .foregroundStyle(.secondary) + Button("Change") { recordingAction = action } + Button("Disable") { disable(action) } + .help("No key will \(title.lowercased()) in \(displayName).") + } + } + } else { + KeybindRow( + label: binding.label, + keyCode: binding.keyCode, + isRecording: isRecording, + onRecord: { keyCode, modifiers, label in + apply(action, keyCode: keyCode, modifiers: modifiers, label: label) + }, + onReset: { clear(action) }, + resetLabel: "Use Global", + shouldShowReset: true, + onClear: { disable(action) }, + clearLabel: "Disable", + clearHelp: "No key will \(title.lowercased()) in \(displayName).", + conflictChecker: conflictChecker(action) + ) + } + } label: { + SettingsRowLabel( + title: title, + description: action == .acceptWord + ? "Disable it to keep \(globalLabel) doing its normal job in this app, such as indenting or moving between fields." + : "The key that inserts the whole suggestion in this app.", + systemImage: action == .acceptWord ? "arrow.right.to.line" : "text.insert" + ) + } + } + + private func resolvedBinding(_ action: PerAppShortcutAction) -> ShortcutResolver.ResolvedBinding { + switch action { + case .acceptWord: return suggestionSettings.resolvedAcceptBinding(forBundleIdentifier: bundleIdentifier) + case .acceptEntireSuggestion: return suggestionSettings.resolvedFullAcceptBinding(forBundleIdentifier: bundleIdentifier) + } + } + + private func apply(_ action: PerAppShortcutAction, keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { + switch action { + case .acceptWord: + suggestionSettings.setPerAppAcceptKey( + bundleIdentifier: bundleIdentifier, displayName: displayName, + keyCode: keyCode, modifiers: modifiers, label: label + ) + case .acceptEntireSuggestion: + suggestionSettings.setPerAppFullAcceptKey( + bundleIdentifier: bundleIdentifier, displayName: displayName, + keyCode: keyCode, modifiers: modifiers, label: label + ) + } + } + + private func clear(_ action: PerAppShortcutAction) { + switch action { + case .acceptWord: suggestionSettings.clearPerAppAcceptKey(bundleIdentifier: bundleIdentifier) + case .acceptEntireSuggestion: suggestionSettings.clearPerAppFullAcceptKey(bundleIdentifier: bundleIdentifier) + } + } + + private func disable(_ action: PerAppShortcutAction) { + apply(action, keyCode: SuggestionSettingsModel.disabledKeyCode, modifiers: [], label: SuggestionSettingsModel.disabledKeyLabel) + } + + private func conflictChecker(_ action: PerAppShortcutAction) -> (CGKeyCode, ShortcutModifierMask) -> String? { + { keyCode, modifiers in + suggestionSettings.conflictingPerAppShortcutName( + forBundleIdentifier: bundleIdentifier, + keyCode: keyCode, + modifiers: modifiers, + excluding: action.shortcutAction + ) + } + } +} + +enum PerAppShortcutAction: Equatable { + case acceptWord + case acceptEntireSuggestion + + var shortcutAction: ShortcutAction { + switch self { + case .acceptWord: return .acceptWord + case .acceptEntireSuggestion: return .acceptEntireSuggestion + } + } +} diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index bc20470d..16578888 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -2,92 +2,181 @@ import AppKit import SwiftUI import UniformTypeIdentifiers -/// App-specific settings for shortcut overrides, exclusions, and integrated terminals. +/// The Apps pane: every app Cotabby knows about, most-used first, and a detail screen per app. +/// +/// An app appears once anything is known about it: typing history was collected there, it is +/// disabled, or it has its own keys or behavior (`AppSettingsList`). Selecting one shows +/// `AppSettingsDetailView`. The list replaces the old separate "Per-App Shortcuts" and "Disabled +/// Apps" sections, whose settings now live on each app's detail screen. struct AppsPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel + /// Owned by `CotabbyAppEnvironment`; supplies per-app input counts, exclusions, and deletion. + @ObservedObject var typingHistory: TypingHistoryStore /// Snapshotted at view-appear time. We deliberately don't subscribe to NSWorkspace launch /// notifications: the panel is not a live process inspector, and re-rendering as random apps /// open and close would make the chips flicker while the user is mid-task. @State private var runningAppSuggestions: [RunningAppSuggestion] = [] - /// A single target prevents multiple key recorders from running at once. - @State private var recordingTarget: RecordingTarget? + @State private var selectedApp: AppSettingsEntry? + @State private var searchText = "" + /// Apps added with "Add App…" that have no settings yet, so they stay listed this session. + @State private var addedBundleIdentifiers: [String] = [] var body: some View { SettingsPaneScaffold { - Section("Per-App Shortcuts") { - Text("Give a specific app its own accept key, disable an accept action there, or " - + "keep inheriting the global shortcut from the Shortcuts pane.") - .font(.caption) - .foregroundStyle(.secondary) + if let selectedApp { + AppSettingsDetailView( + suggestionSettings: suggestionSettings, + typingHistory: typingHistory, + bundleIdentifier: selectedApp.bundleIdentifier, + displayName: selectedApp.displayName, + onBack: { self.selectedApp = nil } + ) + } else { + appListSection + integratedTerminalsSection + runningAppsSection + } + } + .onAppear { + runningAppSuggestions = RunningAppSuggestion.collect() + } + } - if suggestionSettings.perAppShortcutOverrides.isEmpty { - Text("No per-app shortcuts. Cotabby uses the global accept key everywhere.") - .font(.callout) - .foregroundStyle(.secondary) - } else { - ForEach(suggestionSettings.perAppShortcutOverrides) { override in - perAppOverrideRow(override) - } - } + // MARK: - App list - Button("Add App…") { - presentPerAppOverridePicker() - } + private var entries: [AppSettingsEntry] { + AppSettingsList.entries( + inputCounts: typingHistory.recordCountsByApp, + disabledRules: suggestionSettings.disabledAppRules, + overrides: suggestionSettings.perAppShortcutOverrides, + excludedFromHistory: typingHistory.preferences.excludedBundleIdentifiers, + addedBundleIdentifiers: addedBundleIdentifiers, + displayName: AppIconCache.displayName(for:) + ) + } + + private var appListSection: some View { + Section("Apps") { + Text("Choose an app to change how Cotabby behaves there: completions, mid-line suggestions, " + + "autocorrect, accept keys, instructions, and typing history.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .settingsItem(.disabledApps) + + HStack(spacing: 8) { + TextField("Search apps", text: $searchText) + .textFieldStyle(.roundedBorder) + Button("Add App…") { presentAppPicker() } } - Section("Disabled Apps") { - Text("Cotabby won't autocomplete in these apps. Add an app you can't disable from the " - + "menu bar, like a launcher that closes the moment it loses focus.") - .font(.caption) + let visible = AppSettingsList.filter(entries, query: searchText) + if visible.isEmpty { + Text(searchText.isEmpty + ? "No apps yet. Apps appear here once you type in them with typing history on, or add one." + : "No apps match “\(searchText)”.") + .font(.callout) .foregroundStyle(.secondary) - .settingsItem(.disabledApps) + } else { + ForEach(visible) { entry in + appRow(entry) + } + } + } + } - if suggestionSettings.disabledAppRules.isEmpty { - Text("No apps are disabled. Cotabby is active in every supported field.") - .font(.callout) + @ViewBuilder + private func appRow(_ entry: AppSettingsEntry) -> some View { + Button { + selectedApp = entry + } label: { + HStack(spacing: 10) { + Image(nsImage: AppIconCache.icon(for: entry.bundleIdentifier)) + .resizable() + .frame(width: 22, height: 22) + .accessibilityHidden(true) + Text(entry.displayName) + if entry.isDisabled { + Image(systemName: "nosign") .foregroundStyle(.secondary) - } else { - ForEach(suggestionSettings.disabledAppRules) { rule in - disabledAppRuleRow(rule) - } + .help("Completions are off in this app.") + .accessibilityLabel("Completions off") } - - Button("Add App…") { - presentDisabledAppPicker() + if entry.hasOverrides { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(.secondary) + .help("This app has its own settings.") + .accessibilityLabel("Has its own settings") } + Spacer(minLength: 0) + if entry.inputCount > 0 { + Text("\(entry.inputCount)") + .monospacedDigit() + .foregroundStyle(.secondary) + .help("Typing-history inputs collected in this app.") + } + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } - Section("Integrated Terminals") { - Toggle(isOn: suggestInIntegratedTerminalsBinding) { - SettingsRowLabel( - title: "Suggest in Integrated Terminals", - description: "Show ghost text in VS Code and Cursor integrated terminals. " - + "Off by default so suggestions stay out of shell prompts; the editor " - + "and chat in the same window keep suggesting either way.", - systemImage: "terminal" - ) - } - .settingsItem(.suggestInIntegratedTerminals) + private func presentAppPicker() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) + panel.prompt = "Add" + panel.message = "Choose an app to configure." + guard panel.runModal() == .OK, let url = panel.url, + let metadata = ApplicationBundleMetadata(appURL: url) + else { return } + if !addedBundleIdentifiers.contains(metadata.bundleIdentifier) { + addedBundleIdentifiers.append(metadata.bundleIdentifier) + } + selectedApp = entries.first { $0.bundleIdentifier == metadata.bundleIdentifier } + ?? AppSettingsEntry( + bundleIdentifier: metadata.bundleIdentifier, displayName: metadata.displayName, inputCount: 0, + isDisabled: false, hasOverrides: false, isExcludedFromHistory: false + ) + } + + // MARK: - Global app settings + + private var integratedTerminalsSection: some View { + Section("Integrated Terminals") { + Toggle(isOn: suggestInIntegratedTerminalsBinding) { + SettingsRowLabel( + title: "Suggest in Integrated Terminals", + description: "Show ghost text in VS Code and Cursor integrated terminals. " + + "Off by default so suggestions stay out of shell prompts; the editor " + + "and chat in the same window keep suggesting either way.", + systemImage: "terminal" + ) } + .settingsItem(.suggestInIntegratedTerminals) + } + } - if !filteredRunningAppSuggestions.isEmpty { - Section("Suggestions") { - Text("Currently running apps you can disable with one click.") - .font(.caption) - .foregroundStyle(.secondary) + @ViewBuilder + private var runningAppsSection: some View { + if !filteredRunningAppSuggestions.isEmpty { + Section("Suggestions") { + Text("Currently running apps you can disable with one click.") + .font(.caption) + .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 8) { - ForEach(filteredRunningAppSuggestions) { suggestion in - runningAppSuggestionRow(suggestion) - } + VStack(alignment: .leading, spacing: 8) { + ForEach(filteredRunningAppSuggestions) { suggestion in + runningAppSuggestionRow(suggestion) } } } } - .onAppear { - runningAppSuggestions = RunningAppSuggestion.collect() - } } private var suggestInIntegratedTerminalsBinding: Binding { @@ -129,354 +218,6 @@ struct AppsPaneView: View { } .buttonStyle(.plain) } - - @ViewBuilder - private func perAppOverrideRow(_ override: PerAppShortcutOverride) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Image(nsImage: icon(forBundleIdentifier: override.bundleIdentifier)) - .resizable() - .frame(width: 28, height: 28) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text(override.displayName) - Text(override.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Spacer(minLength: 0) - - Button { - suggestionSettings.removePerAppOverride(bundleIdentifier: override.bundleIdentifier) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .accessibilityLabel("Remove per-app shortcuts for \(override.displayName)") - .help("Remove this app's overrides. Cotabby will use the global accept keys here.") - } - - perAppBindingRow( - override: override, - action: .acceptWord, - title: "Accept Word", - inheritsHelp: "Uses the global shortcut (\(suggestionSettings.acceptanceKeyLabel)). " - + "Click Change to set a custom key for \(override.displayName)." - ) - perAppBindingRow( - override: override, - action: .acceptEntireSuggestion, - title: "Accept Entire Suggestion", - inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceKeyLabel)). " - + "Click Change to set a custom key for \(override.displayName)." - ) - } - .padding(.vertical, 4) - } - - @ViewBuilder - private func perAppBindingRow( - override: PerAppShortcutOverride, - action: PerAppShortcutAction, - title: String, - inheritsHelp: String - ) -> some View { - let inherits = (action == .acceptWord && override.acceptance == nil) - || (action == .acceptEntireSuggestion && override.fullAcceptance == nil) - let recordingBinding = recordingBinding(forBundleIdentifier: override.bundleIdentifier, action: action) - let binding = perAppBinding(override: override, action: action) - - HStack(alignment: .center, spacing: 12) { - Text(title) - .font(.callout) - .frame(width: 180, alignment: .leading) - - if inherits { - Text("Uses global (\(binding.label))") - .font(.caption) - .foregroundStyle(.secondary) - .help(inheritsHelp) - - if recordingBinding.wrappedValue { - KeyRecorderView( - onKeyRecorded: { keyCode, modifiers, recordedLabel in - applyPerAppBinding( - override: override, - action: action, - keyCode: keyCode, - modifiers: modifiers, - label: recordedLabel - ) - recordingTarget = nil - }, - onCancelled: { recordingTarget = nil }, - conflictChecker: perAppConflictChecker( - bundleIdentifier: override.bundleIdentifier, - action: action - ) - ) - } else { - Button("Change") { - recordingTarget = RecordingTarget( - bundleIdentifier: override.bundleIdentifier, - action: action - ) - } - - Button("Disable") { - disablePerAppBinding(override: override, action: action) - } - .help("No key will perform \(title.lowercased()) in \(override.displayName).") - } - } else { - KeybindRow( - label: binding.label, - keyCode: binding.keyCode, - isRecording: recordingBinding, - onRecord: { keyCode, modifiers, recordedLabel in - applyPerAppBinding( - override: override, - action: action, - keyCode: keyCode, - modifiers: modifiers, - label: recordedLabel - ) - }, - onReset: { clearPerAppBinding(override: override, action: action) }, - resetLabel: "Use Global", - shouldShowReset: true, - onClear: { disablePerAppBinding(override: override, action: action) }, - clearLabel: "Disable", - clearHelp: "No key will perform \(title.lowercased()) in \(override.displayName).", - conflictChecker: perAppConflictChecker( - bundleIdentifier: override.bundleIdentifier, - action: action - ) - ) - } - } - } - - private func recordingBinding( - forBundleIdentifier bundleIdentifier: String, - action: PerAppShortcutAction - ) -> Binding { - Binding( - get: { - recordingTarget == RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) - }, - set: { isRecording in - if isRecording { - recordingTarget = RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) - } else if recordingTarget == RecordingTarget(bundleIdentifier: bundleIdentifier, action: action) { - recordingTarget = nil - } - } - ) - } - - private func perAppBinding( - override: PerAppShortcutOverride, - action: PerAppShortcutAction - ) -> ShortcutResolver.ResolvedBinding { - switch action { - case .acceptWord: - return suggestionSettings.resolvedAcceptBinding( - forBundleIdentifier: override.bundleIdentifier - ) - case .acceptEntireSuggestion: - return suggestionSettings.resolvedFullAcceptBinding( - forBundleIdentifier: override.bundleIdentifier - ) - } - } - - private func applyPerAppBinding( - override: PerAppShortcutOverride, - action: PerAppShortcutAction, - keyCode: CGKeyCode, - modifiers: ShortcutModifierMask, - label: String - ) { - switch action { - case .acceptWord: - suggestionSettings.setPerAppAcceptKey( - bundleIdentifier: override.bundleIdentifier, - displayName: override.displayName, - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - case .acceptEntireSuggestion: - suggestionSettings.setPerAppFullAcceptKey( - bundleIdentifier: override.bundleIdentifier, - displayName: override.displayName, - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - } - } - - private func clearPerAppBinding( - override: PerAppShortcutOverride, - action: PerAppShortcutAction - ) { - switch action { - case .acceptWord: - suggestionSettings.clearPerAppAcceptKey(bundleIdentifier: override.bundleIdentifier) - case .acceptEntireSuggestion: - suggestionSettings.clearPerAppFullAcceptKey(bundleIdentifier: override.bundleIdentifier) - } - } - - private func disablePerAppBinding( - override: PerAppShortcutOverride, - action: PerAppShortcutAction - ) { - applyPerAppBinding( - override: override, - action: action, - keyCode: SuggestionSettingsModel.disabledKeyCode, - modifiers: [], - label: SuggestionSettingsModel.disabledKeyLabel - ) - } - - private func perAppConflictChecker( - bundleIdentifier: String, - action: PerAppShortcutAction - ) -> (CGKeyCode, ShortcutModifierMask) -> String? { - { keyCode, modifiers in - suggestionSettings.conflictingPerAppShortcutName( - forBundleIdentifier: bundleIdentifier, - keyCode: keyCode, - modifiers: modifiers, - excluding: action.shortcutAction - ) - } - } - - private func presentPerAppOverridePicker() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.application] - panel.allowsMultipleSelection = true - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) - panel.prompt = "Add" - panel.message = "Choose apps that should get their own accept shortcut." - - guard panel.runModal() == .OK else { return } - - for url in panel.urls { - guard let metadata = ApplicationBundleMetadata(appURL: url) else { continue } - suggestionSettings.addPerAppShortcutApp( - bundleIdentifier: metadata.bundleIdentifier, - displayName: metadata.displayName - ) - } - } - - private func icon(forBundleIdentifier bundleIdentifier: String) -> NSImage { - guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { - return NSWorkspace.shared.icon(for: .applicationBundle) - } - return NSWorkspace.shared.icon(forFile: appURL.path) - } - - @ViewBuilder - private func disabledAppRuleRow(_ rule: DisabledApplicationRule) -> some View { - HStack(spacing: 12) { - Image(nsImage: icon(for: rule)) - .resizable() - .frame(width: 28, height: 28) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text(rule.displayName) - - Text(rule.bundleIdentifier) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Spacer(minLength: 0) - - Button { - suggestionSettings.removeDisabledApplication( - bundleIdentifier: rule.bundleIdentifier - ) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - } - } - - /// Bundle IDs are durable; app paths are not. Resolve the current app URL at render time so - /// Settings naturally picks up app updates, moves, or reinstalls without persisting UI cache. - private func icon(for rule: DisabledApplicationRule) -> NSImage { - guard let appURL = NSWorkspace.shared.urlForApplication( - withBundleIdentifier: rule.bundleIdentifier - ) else { - return NSWorkspace.shared.icon(for: .applicationBundle) - } - return NSWorkspace.shared.icon(forFile: appURL.path) - } - - /// Lets the user disable Cotabby in an app they can't reach from the menu bar. The menu-bar - /// "Enable in " switch only targets the frontmost app, so a launcher like Raycast or - /// Spotlight (which dismisses itself the instant the menu bar is clicked) can never be turned - /// off that way. An open panel names any installed app whether or not it is running. - private func presentDisabledAppPicker() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.application] - panel.allowsMultipleSelection = true - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) - panel.prompt = "Disable" - panel.message = "Choose apps where Cotabby should not autocomplete." - - guard panel.runModal() == .OK else { - return - } - - for url in panel.urls { - guard let metadata = ApplicationBundleMetadata(appURL: url) else { - continue - } - suggestionSettings.disableApplication( - bundleIdentifier: metadata.bundleIdentifier, - displayName: metadata.displayName - ) - } - } -} - -/// Identifies the only per-app key recorder allowed to be active. -private struct RecordingTarget: Equatable { - let bundleIdentifier: String - let action: PerAppShortcutAction -} - -private enum PerAppShortcutAction: Equatable { - case acceptWord - case acceptEntireSuggestion - - var shortcutAction: ShortcutAction { - switch self { - case .acceptWord: return .acceptWord - case .acceptEntireSuggestion: return .acceptEntireSuggestion - } - } } /// One disable-able app surfaced from the running-process list. Captures the icon up front so the diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index 89726b44..e7fefac0 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -149,7 +149,7 @@ struct SettingsContainerView: View { case .shortcuts: ShortcutsPaneView(suggestionSettings: suggestionSettings) case .apps: - AppsPaneView(suggestionSettings: suggestionSettings) + AppsPaneView(suggestionSettings: suggestionSettings, typingHistory: typingHistoryStore) case .permissions: PermissionsPaneView( permissionManager: permissionManager, diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 72d228ad..b4a24522 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -173,7 +173,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptWord: return "Accept Word" case .acceptEntireSuggestion: return "Accept Entire Suggestion" case .toggleTabby: return "Toggle Cotabby" - case .disabledApps: return "Disabled Apps" + case .disabledApps: return "App Settings" case .suggestInIntegratedTerminals: return "Suggest in Integrated Terminals" case .accessibility: return "Accessibility" case .inputMonitoring: return "Input Monitoring" @@ -386,7 +386,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptWord: return "The key that inserts the next word." case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." case .toggleTabby: return "A global hotkey that turns Cotabby on or off." - case .disabledApps: return "Apps where Cotabby never autocompletes." + case .disabledApps: return "Per-app completions, autocorrect, keys, instructions, and history." case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." case .accessibility: return "Required to read the focused field and caret." case .inputMonitoring: return "Required to see keystrokes and the accept key." @@ -602,7 +602,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { "enable", "disable", "keybind", "binding", "tabby"] case .disabledApps: return ["apps", "disable", "exclude", "block", "ignore", "blacklist", - "deny list", "exception", "app exclusion", "skip", "off in"] + "deny list", "exception", "app exclusion", "skip", "off in", "per app", + "app settings", "mid-line", "midline", "autocorrect", "tab key", "instructions", + "collected inputs", "typing history"] case .suggestInIntegratedTerminals: return ["terminal", "terminals", "integrated terminal", "vscode", "vs code", "cursor", "shell", "xterm", "command line", "cli", "console", diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPerAppTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPerAppTests.swift new file mode 100644 index 00000000..9110a2b7 --- /dev/null +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorPerAppTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Drives the real coordinator to pin that per-app behavior reaches the generation gate: with +/// mid-line completions off in an app, text after the caret on the same line means no request. +@MainActor +final class SuggestionCoordinatorPerAppTests: XCTestCase { + private let app = "com.example.TestApp" + + private func requestCount(midLine: PerAppToggle, trailingText: String) async -> Int { + let rig = makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot( + bundleIdentifier: app, precedingText: "Hello ", trailingText: trailingText + ), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + perAppBehaviors: [app: PerAppBehavior(midLineCompletions: midLine)] + ) + ) + defer { rig.coordinator.stop() } + rig.engine.resultProvider = { request in + SuggestionResult(generation: request.generation, rawText: "there", text: "there", latency: 0.01) + } + rig.coordinator.schedulePrediction() + // Give an allowed request time to arrive; a blocked one never does. + try? await Task.sleep(nanoseconds: 200_000_000) + return rig.engine.requests.count + } + + func test_midLineOffSkipsRequestsWhenTextFollowsOnTheLine() async { + let blocked = await requestCount(midLine: .off, trailingText: "world") + XCTAssertEqual(blocked, 0) + } + + func test_midLineOffStillSuggestsAtTheEndOfALine() async { + let allowed = await requestCount(midLine: .off, trailingText: "") + XCTAssertGreaterThan(allowed, 0) + } + + func test_midLineDefaultSuggestsWithTextAfterTheCaret() async { + let allowed = await requestCount(midLine: .useDefault, trailingText: "world") + XCTAssertGreaterThan(allowed, 0) + } +} diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift index c0cd176c..d2b7562a 100644 --- a/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelTests.swift @@ -1018,4 +1018,34 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertLessThanOrEqual(model.ghostFontSizeFloor, model.ghostFontSizeCeiling) } + + func test_perAppBehaviorPersistsReachesTheSnapshotAndClearsWhenEmpty() { + let model = makeModel() + + model.updatePerAppBehavior(bundleIdentifier: "com.microsoft.teams2", displayName: "Microsoft Teams") { + $0.midLineCompletions = .off + $0.instructions = "Reply informally." + } + + let reloaded = makeModel() + XCTAssertEqual(reloaded.perAppBehavior(forBundleIdentifier: "com.microsoft.teams2").midLineCompletions, .off) + XCTAssertEqual(reloaded.snapshot.perAppBehaviors["com.microsoft.teams2"]?.instructions, "Reply informally.") + + reloaded.updatePerAppBehavior(bundleIdentifier: "com.microsoft.teams2", displayName: "Microsoft Teams") { + $0 = PerAppBehavior() + } + XCTAssertNil(reloaded.snapshot.perAppBehaviors["com.microsoft.teams2"]) + } + + func test_perAppInstructionsAreCapped() { + let model = makeModel() + + model.updatePerAppBehavior(bundleIdentifier: "com.apple.mail", displayName: "Mail") { + $0.instructions = String(repeating: "a", count: PerAppBehavior.maximumInstructionCharacters + 50) + } + + XCTAssertEqual(model.perAppBehavior(forBundleIdentifier: "com.apple.mail").instructions.count, + PerAppBehavior.maximumInstructionCharacters) + } + } diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index 60e104e5..e9799935 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -241,4 +241,19 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 0) XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) } + + func test_countsByAppAndPerAppDeleteKeepOtherApps() async throws { + let store = makeStore() + var rows = signOffRows(count: 3) + rows.append(["appBundleIdentifier": "net.whatsapp.WhatsApp", "textUpToCursor": "See you tomorrow at the office then!"]) + await store.importCotypistExport(from: try writeExport(rows)) + + XCTAssertEqual(store.recordCountsByApp, ["com.microsoft.Outlook": 3, "net.whatsapp.WhatsApp": 1]) + + store.deleteRecords(forBundleIdentifier: "com.microsoft.Outlook") + + XCTAssertEqual(store.recordCountsByApp, ["net.whatsapp.WhatsApp": 1]) + XCTAssertEqual(store.recordCount, 1) + } + } diff --git a/CotabbyTests/Support/Settings/PerAppSettingsTests.swift b/CotabbyTests/Support/Settings/PerAppSettingsTests.swift new file mode 100644 index 00000000..b26cf76c --- /dev/null +++ b/CotabbyTests/Support/Settings/PerAppSettingsTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import Cotabby + +final class PerAppSettingsResolverTests: XCTestCase { + private let teams = "com.microsoft.teams2" + + private func settings(_ behavior: PerAppBehavior? = nil, extendedContext: String = "", autocorrectOn: Bool = true) + -> SuggestionSettingsSnapshot { + CotabbyTestFixtures.settingsSnapshot( + extendedContext: extendedContext, + suppressCompletionsOnTypo: autocorrectOn, + offerTypoCorrections: autocorrectOn, + automaticallyFixTypos: true, + perAppBehaviors: behavior.map { [teams: $0] } ?? [:] + ) + } + + func test_midLineDefaultsOnAndFollowsTheAppChoice() { + XCTAssertTrue(PerAppSettingsResolver.allowsMidLineCompletions(bundleIdentifier: teams, settings: settings())) + XCTAssertFalse(PerAppSettingsResolver.allowsMidLineCompletions( + bundleIdentifier: teams, settings: settings(PerAppBehavior(midLineCompletions: .off)) + )) + XCTAssertTrue(PerAppSettingsResolver.allowsMidLineCompletions( + bundleIdentifier: "com.apple.mail", settings: settings(PerAppBehavior(midLineCompletions: .off)) + ), "Another app keeps the default") + } + + func test_autocorrectOffDisablesTheWholeTypoGate() { + let typo = PerAppSettingsResolver.typoSettings( + bundleIdentifier: teams, settings: settings(PerAppBehavior(autocorrect: .off)) + ) + XCTAssertFalse(typo.suppressCompletionsOnTypo) + XCTAssertFalse(typo.offerTypoCorrections) + XCTAssertFalse(typo.automaticallyFixTypos) + } + + func test_autocorrectOnEnablesHidingAndOfferingButKeepsGlobalAutoFix() { + let snapshot = CotabbyTestFixtures.settingsSnapshot( + suppressCompletionsOnTypo: false, offerTypoCorrections: false, automaticallyFixTypos: false, + perAppBehaviors: [teams: PerAppBehavior(autocorrect: .on)] + ) + let typo = PerAppSettingsResolver.typoSettings(bundleIdentifier: teams, settings: snapshot) + XCTAssertTrue(typo.suppressCompletionsOnTypo) + XCTAssertTrue(typo.offerTypoCorrections) + XCTAssertFalse(typo.automaticallyFixTypos) + } + + func test_defaultAutocorrectUsesTheGlobals() { + let typo = PerAppSettingsResolver.typoSettings(bundleIdentifier: teams, settings: settings(autocorrectOn: false)) + XCTAssertFalse(typo.suppressCompletionsOnTypo) + XCTAssertTrue(typo.automaticallyFixTypos) + } + + func test_appInstructionsJoinGlobalExtendedContext() { + let snapshot = settings(PerAppBehavior(instructions: " Reply informally. "), extendedContext: "I work at Imperum.") + + XCTAssertEqual( + PerAppSettingsResolver.extendedContext(bundleIdentifier: teams, settings: snapshot), + "I work at Imperum.\nReply informally." + ) + XCTAssertEqual(PerAppSettingsResolver.extendedContext(bundleIdentifier: "x", settings: snapshot), "I work at Imperum.") + XCTAssertNil(PerAppSettingsResolver.extendedContext(bundleIdentifier: "x", settings: settings())) + } + + func test_requestFactoryUsesTheAppsInstructions() { + let context = CotabbyTestFixtures.focusedInputContext(bundleIdentifier: teams, precedingText: "Hi team") + let request = SuggestionRequestFactory.buildRequest( + context: context, settings: settings(PerAppBehavior(instructions: "Reply informally.")), configuration: .standard + ).request + + XCTAssertEqual(request.extendedContext, "Reply informally.") + } + + func test_midLineOffBlocksOnlyWhenTextFollowsOnTheSameLine() { + XCTAssertFalse(SuggestionRequestFactory.shouldGenerateSuggestion( + for: "Hello ", trailingText: "world", allowsMidLine: false + )) + XCTAssertTrue(SuggestionRequestFactory.shouldGenerateSuggestion( + for: "Hello ", trailingText: " \nnext line", allowsMidLine: false + )) + XCTAssertTrue(SuggestionRequestFactory.shouldGenerateSuggestion( + for: "Hello ", trailingText: "world", allowsMidLine: true + )) + } +} + +final class AppSettingsListTests: XCTestCase { + func test_listMergesEveryOwnerAndSortsByUse() { + let entries = AppSettingsList.entries( + inputCounts: ["net.whatsapp.WhatsApp": 869, "com.googlecode.iterm2": 997, "unknown": 3], + disabledRules: [DisabledApplicationRule(bundleIdentifier: "com.1password", displayName: "1Password")], + overrides: [PerAppShortcutOverride(bundleIdentifier: "com.apple.Terminal", displayName: "Terminal", + behavior: PerAppBehavior(midLineCompletions: .off))], + excludedFromHistory: ["com.apple.Notes"], + displayName: { $0 == "com.googlecode.iterm2" ? "iTerm" : $0 } + ) + + XCTAssertEqual(entries.map(\.bundleIdentifier).prefix(2), ["com.googlecode.iterm2", "net.whatsapp.WhatsApp"]) + XCTAssertEqual(entries.first?.displayName, "iTerm") + XCTAssertFalse(entries.contains { $0.bundleIdentifier == "unknown" }) + XCTAssertEqual(entries.first { $0.bundleIdentifier == "com.1password" }?.isDisabled, true) + XCTAssertEqual(entries.first { $0.bundleIdentifier == "com.apple.Terminal" }?.hasOverrides, true) + XCTAssertEqual(entries.first { $0.bundleIdentifier == "com.apple.Notes" }?.isExcludedFromHistory, true) + XCTAssertEqual(entries.count, 5) + } + + func test_searchMatchesNameOrBundleIdentifier() { + let entries = AppSettingsList.entries( + inputCounts: ["com.microsoft.teams2": 249, "com.apple.mail": 94], + disabledRules: [], overrides: [], excludedFromHistory: [], + displayName: { $0 == "com.microsoft.teams2" ? "Microsoft Teams" : "Mail" } + ) + + XCTAssertEqual(AppSettingsList.filter(entries, query: "teams").map(\.displayName), ["Microsoft Teams"]) + XCTAssertEqual(AppSettingsList.filter(entries, query: "com.apple").map(\.displayName), ["Mail"]) + XCTAssertEqual(AppSettingsList.filter(entries, query: " ").count, 2) + } +} diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 105cf90c..d0dd33bb 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -272,7 +272,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: Bool = false, offerTypoCorrections: Bool = false, enabledSpellingDictionaryCodes: [String] = SpellingDictionaryCatalog.defaultEnabledCodes, - automaticallyFixTypos: Bool = false + automaticallyFixTypos: Bool = false, + perAppBehaviors: [String: PerAppBehavior] = [:] ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -305,7 +306,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: suppressCompletionsOnTypo, offerTypoCorrections: offerTypoCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + perAppBehaviors: perAppBehaviors ) } } From 68f9faa7f9575f612d157e8f1c9e5b9f029f1c1c Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 14:51:26 +0200 Subject: [PATCH 3/3] Fix typing-history review findings: delete races, duplicates, terminals, prompt quotes - Delete All can no longer be undone by work already in flight. Writes and deletes go through TypingHistoryWriter (one lock; a delete raises a generation so saves captured earlier are skipped, and a save sequence stops an older snapshot from overwriting a newer one). A load or an import that finishes after Delete All discards its result. - Returning to a field continues its record instead of duplicating it: the field key no longer includes the focus sequence, and a returning field resumes only when its text still starts the same way, so a reused AX identifier cannot overwrite another field's record. - Terminal fields (terminal apps and integrated terminals) are never recorded, and the settings gate is evaluated only when text changed. - The history prompt section is all or nothing, so budget trimming can never leave an unclosed quote before the caret text. - Delete All is disabled while an import runs. - Cotypist's "unknown.bundle" placeholder maps to the unknown app. Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 6 ++ .../Services/History/TypingHistoryStore.swift | 102 ++++++++++++++---- .../History/TypingHistoryWriter.swift | 44 ++++++++ .../History/CotypistExportImporter.swift | 12 ++- .../BaseCompletionPromptRenderer.swift | 31 ++++-- .../Panes/TypingHistorySectionView.swift | 2 +- .../History/TypingHistoryStoreTests.swift | 88 +++++++++++++++ .../TypingHistoryPhraseEngineTests.swift | 12 +++ 8 files changed, 265 insertions(+), 32 deletions(-) create mode 100644 Cotabby/Services/History/TypingHistoryWriter.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index c17ed32b..f10fed4d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -227,6 +227,7 @@ 380D6D04743E5F0A8A71228E /* HostFontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7344003A6327C5FBDA581028 /* HostFontRegistryTests.swift */; }; 3844577817DE8EA5D41E3AC0 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 3870BA44775E528EEA9E2FC6 /* FocusedInputPollingSignature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1891267512941C84BEA4172 /* FocusedInputPollingSignature.swift */; }; + 38E9128D7C7D40C05DAFBE3D /* TypingHistoryWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */; }; 39332A6824B1935AE3B0D8C0 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 3935AEF7D139557677D09318 /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF96999230079A768BEFD0FC /* KeycapView.swift */; }; 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; @@ -586,6 +587,7 @@ 936F87925A22DAC58525B60F /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC756DF8E0032B39099D2BF8 /* DownloadFileRescuer.swift */; }; 940E71A409B4FCDD63642471 /* EmojiVariantResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD0F17E1E2BB8FE75A6E75E /* EmojiVariantResolverTests.swift */; }; 94131FA2B9717436CF0A70F1 /* StreamedGhostTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3DF6826CD5E4EF695EF163 /* StreamedGhostTextPolicy.swift */; }; + 941CB45BCF47F4E7CA5320EA /* TypingHistoryWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */; }; 9447C6F6A30C51877236EC01 /* InlineFeatureTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF21767F255146EA968B267D /* InlineFeatureTestDoubles.swift */; }; 945552C364C43F920138E58B /* LlamaDecodeGateDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F0F084B3A58EDA94A32324 /* LlamaDecodeGateDefaultsTests.swift */; }; 948AD3BD25C113539DF12FD7 /* VisualContextModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A51B1559F4CE6728D39C959 /* VisualContextModels.swift */; }; @@ -1545,6 +1547,7 @@ D1891267512941C84BEA4172 /* FocusedInputPollingSignature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputPollingSignature.swift; sourceTree = ""; }; D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocator.swift; sourceTree = ""; }; D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; + D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHistoryWriter.swift; sourceTree = ""; }; D381F1752705DC80E491B025 /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyBrand.swift; sourceTree = ""; }; D47E1471C110614F0D07E399 /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; @@ -3703,6 +3706,7 @@ children = ( 1E6764695F1EAC37A043A61E /* TypingHistoryStore.swift */, ED222C2589A4787AFCA90EFD /* TypingHistoryVault.swift */, + D3588A6D05E37ED8D4332CD2 /* TypingHistoryWriter.swift */, ); path = History; sourceTree = ""; @@ -4242,6 +4246,7 @@ 620A8133C64753E17AD7CD67 /* TypingHistorySectionView.swift in Sources */, 6C75BC36C18440B54A3A35AC /* TypingHistoryStore.swift in Sources */, 73E3EFA8960FC779920935C4 /* TypingHistoryVault.swift in Sources */, + 38E9128D7C7D40C05DAFBE3D /* TypingHistoryWriter.swift in Sources */, BDF13BDA6D30BB8FF755AC32 /* TypingPredictionCandidate.swift in Sources */, C0468063222C5FEA7C1CFAB3 /* TypoCaseTransfer.swift in Sources */, A88F3C7039E8DDB71C5D6246 /* TypoGate.swift in Sources */, @@ -4578,6 +4583,7 @@ AAC2E374027B9A0E983D966E /* TypingHistorySectionView.swift in Sources */, 23F0FA44C965F97181F082D8 /* TypingHistoryStore.swift in Sources */, 2BA57F29DDD4CEB327CD805E /* TypingHistoryVault.swift in Sources */, + 941CB45BCF47F4E7CA5320EA /* TypingHistoryWriter.swift in Sources */, 733A13BF72DCBBDC37952DD9 /* TypingPredictionCandidate.swift in Sources */, CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */, 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */, diff --git a/Cotabby/Services/History/TypingHistoryStore.swift b/Cotabby/Services/History/TypingHistoryStore.swift index f4940938..7211525d 100644 --- a/Cotabby/Services/History/TypingHistoryStore.swift +++ b/Cotabby/Services/History/TypingHistoryStore.swift @@ -39,6 +39,14 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { static let minimumRecordedCharacters = 20 private let vault: TypingHistoryVault + /// Every write and delete goes through this so a deletion can never be undone by a save that + /// was already running (see `TypingHistoryWriter`). + private let writer: TypingHistoryWriter + /// Bumped by Delete All. Work that started before a deletion (a load, an import, a save) + /// compares its captured value and discards its result instead of restoring deleted history. + private var persistenceGeneration = 0 + /// Numbers each captured save so an older snapshot can never overwrite a newer one. + private var saveSequence = 0 private let userDefaults: UserDefaults private var records: [TypingHistoryRecord] = [] private var index: TypingHistoryIndex? @@ -46,10 +54,13 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { private var rebuildGeneration = 0 private var saveTask: Task? private var activeRecording: ActiveRecording? - /// The field most recently finished. Accessibility can briefly report a field as unsupported - /// mid-typing; when the same field comes back, recording resumes into the same record instead - /// of starting a duplicate. - private var lastFinishedRecording: ActiveRecording? + /// Recently finished fields, by field key. When the user returns to one (or Accessibility + /// briefly reported it unsupported), recording resumes into the same record instead of + /// starting a duplicate of the same text. + private var recentRecordings: [String: ActiveRecording] = [:] + private static let maximumRecentRecordings = 64 + /// How much of a field's opening must match for a returning field to count as the same document. + private static let sameDocumentOpeningLength = 40 private var exampleCache: (key: String, examples: [String])? /// The field being typed in right now. Its raw text is kept here and only scrubbed and copied @@ -74,6 +85,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { init(vault: TypingHistoryVault = .standard(), userDefaults: UserDefaults = .standard, loadsArchive: Bool = true) { self.vault = vault + self.writer = TypingHistoryWriter(vault: vault) self.userDefaults = userDefaults preferences = TypingHistoryPreferences( isUsingHistory: userDefaults.object(forKey: DefaultsKey.isUsingHistory) as? Bool @@ -115,7 +127,7 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { if excluded, activeRecording?.bundleIdentifier == bundleIdentifier { // Excluding an app mid-field discards that field's unsaved text instead of keeping it. activeRecording = nil - lastFinishedRecording = nil + recentRecordings = recentRecordings.filter { $0.value.bundleIdentifier != bundleIdentifier } } } @@ -123,8 +135,11 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { func loadArchive() async { let vault = vault + let generation = persistenceGeneration do { let loaded = try await Task.detached(priority: .utility) { try vault.load() }.value + // Delete All ran while the archive was decrypting: the loaded records are gone now. + guard generation == persistenceGeneration else { return } records = loaded recordCount = loaded.count status = .ready @@ -141,8 +156,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready else { return } saveTask?.cancel() materializeActiveRecording() + saveSequence += 1 do { - try vault.save(records) + try writer.save(records, generation: persistenceGeneration, sequence: saveSequence) } catch { CotabbyLogger.app.error("Typing history could not be saved: \(error)") } @@ -155,10 +171,15 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { try? await Task.sleep(nanoseconds: 5_000_000_000) guard let self, !Task.isCancelled else { return } self.materializeActiveRecording() + self.saveSequence += 1 let snapshot = self.records - let vault = self.vault + let writer = self.writer + let generation = self.persistenceGeneration + let sequence = self.saveSequence do { - try await Task.detached(priority: .utility) { try vault.save(snapshot) }.value + try await Task.detached(priority: .utility) { + try writer.save(snapshot, generation: generation, sequence: sequence) + }.value } catch { CotabbyLogger.app.error("Typing history could not be saved: \(error)") } @@ -195,23 +216,29 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready, preferences.isRecording, case .supported = snapshot.capability, let input = snapshot.context, !input.isSecure, - !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier), - isAllowed() + !Self.isTerminal(input), + !preferences.excludedBundleIdentifiers.contains(input.bundleIdentifier) else { finishActiveRecording() return } - let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)|\(input.focusChangeSequence)" + // The focus sequence is left out on purpose: leaving a field and coming back starts a new + // focus session, but it is the same document and should stay one record. + let fieldKey = "\(input.bundleIdentifier)|\(input.processIdentifier)|\(input.elementIdentifier)" let text = input.precedingText + input.trailingText + // Unchanged text in the same field is the common case on a 50 ms poll; answer it before + // building the settings snapshot `isAllowed` needs. + if activeRecording?.fieldKey == fieldKey, activeRecording?.rawText == text { return } + guard isAllowed() else { + finishActiveRecording() + return + } + if activeRecording?.fieldKey != fieldKey { finishActiveRecording() - if var resumed = lastFinishedRecording, resumed.fieldKey == fieldKey { - resumed.rawText = text - resumed.rawTypedLength = input.precedingText.count - activeRecording = resumed - } else { - activeRecording = ActiveRecording( + activeRecording = resumedRecording(fieldKey: fieldKey, text: text, typedLength: input.precedingText.count) + ?? ActiveRecording( fieldKey: fieldKey, recordID: UUID(), bundleIdentifier: input.bundleIdentifier, @@ -220,21 +247,44 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { rawText: text, rawTypedLength: input.precedingText.count ) - } return } - guard activeRecording?.rawText != text else { return } activeRecording?.rawText = text activeRecording?.rawTypedLength = input.precedingText.count scheduleSave() } + /// Terminals are never recorded: their text is shell commands and output, where secrets are + /// common and nothing is the user's prose. + private static func isTerminal(_ input: FocusedInputSnapshot) -> Bool { + input.isIntegratedTerminal || AppSurfaceClassifier.classify(bundleIdentifier: input.bundleIdentifier) == .terminal + } + + /// Continues the record of a recently finished field when the user comes back to it. The text + /// must still start the same way: Accessibility element identifiers can be reused by a + /// different field, and resuming into the wrong record would overwrite its text. + private func resumedRecording(fieldKey: String, text: String, typedLength: Int) -> ActiveRecording? { + guard var recent = recentRecordings[fieldKey] else { return nil } + let opening = recent.rawText.prefix(Self.sameDocumentOpeningLength) + guard !opening.isEmpty, text.hasPrefix(opening) || recent.rawText.hasPrefix(text.prefix(Self.sameDocumentOpeningLength)) + else { return nil } + recent.rawText = text + recent.rawTypedLength = typedLength + return recent + } + /// Commits the active field to `records` and makes it searchable. Called when focus moves to /// another field, recording stops, or the app is no longer eligible. private func finishActiveRecording() { guard activeRecording != nil else { return } let changed = materializeActiveRecording() - lastFinishedRecording = activeRecording + if let finished = activeRecording { + recentRecordings[finished.fieldKey] = finished + if recentRecordings.count > Self.maximumRecentRecordings, let oldest = recentRecordings.values + .min(by: { $0.createdAt < $1.createdAt }) { + recentRecordings[oldest.fieldKey] = nil + } + } activeRecording = nil if changed { scheduleSave() @@ -293,10 +343,16 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { guard status == .ready, !isImporting else { return } isImporting = true defer { isImporting = false } + let generation = persistenceGeneration do { let imported = try await Task.detached(priority: .userInitiated) { try CotypistExportImporter.records(fromExport: Data(contentsOf: url)) }.value + // Delete All ran while the file was being read; adding the import now would partly undo it. + guard generation == persistenceGeneration else { + lastImportMessage = "Import cancelled because typing history was deleted." + return + } let knownTexts = Set(records.map(\.text)) let fresh = imported.filter { !knownTexts.contains($0.text) } records.append(contentsOf: fresh) @@ -314,8 +370,9 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { /// Removes every record, the encrypted file, and its Keychain key. func deleteAll() { saveTask?.cancel() + persistenceGeneration += 1 activeRecording = nil - lastFinishedRecording = nil + recentRecordings = [:] records = [] recordCount = 0 index = nil @@ -324,7 +381,8 @@ final class TypingHistoryStore: ObservableObject, SuggestionHistoryProviding { rebuildGeneration += 1 lastImportMessage = nil do { - try vault.destroy() + // Waits for any save already writing, then deletes; saves captured earlier are dropped. + try writer.destroy(generation: persistenceGeneration) status = .ready } catch { CotabbyLogger.app.error("Typing history could not be deleted: \(error)") diff --git a/Cotabby/Services/History/TypingHistoryWriter.swift b/Cotabby/Services/History/TypingHistoryWriter.swift new file mode 100644 index 00000000..258fa46f --- /dev/null +++ b/Cotabby/Services/History/TypingHistoryWriter.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Serializes every write and delete of the typing-history archive, and drops writes that a +/// deletion has made stale. +/// +/// Why this exists: saves run on background tasks with a snapshot of the records. Cancelling the +/// task that awaits a save does not stop a save that has already started, so without this a save +/// begun just before Delete All could finish afterwards, create a fresh Keychain key, and write the +/// deleted history back. Here, every operation takes one lock, so a delete waits for an in-flight +/// save, and each delete raises `minimumGeneration` so any save captured before it is skipped. +/// +/// It is a lock rather than an actor because the termination flush must write synchronously on +/// the main thread; both paths share the same ordering rules. +nonisolated final class TypingHistoryWriter: @unchecked Sendable { + private let vault: TypingHistoryVault + private let lock = NSLock() + /// Writes captured at an older generation than this are stale (a deletion happened since). + private var minimumGeneration = 0 + /// Writes are numbered as they are captured, so an older snapshot finishing late cannot + /// overwrite a newer one already on disk. + private var lastWrittenSequence = 0 + + init(vault: TypingHistoryVault) { + self.vault = vault + } + + /// Writes `records` unless a deletion or a newer write has made them stale. + func save(_ records: [TypingHistoryRecord], generation: Int, sequence: Int) throws { + try lock.withLock { + guard generation >= minimumGeneration, sequence > lastWrittenSequence else { return } + try vault.save(records) + lastWrittenSequence = sequence + } + } + + /// Deletes the archive and its key, after any write already in progress, and marks every write + /// captured before `generation` as stale. + func destroy(generation: Int) throws { + try lock.withLock { + minimumGeneration = max(minimumGeneration, generation) + try vault.destroy() + } + } +} diff --git a/Cotabby/Support/History/CotypistExportImporter.swift b/Cotabby/Support/History/CotypistExportImporter.swift index 67d44c94..934dc9f5 100644 --- a/Cotabby/Support/History/CotypistExportImporter.swift +++ b/Cotabby/Support/History/CotypistExportImporter.swift @@ -47,7 +47,7 @@ nonisolated enum CotypistExportImporter { before: row.textUpToCursor ?? "", after: row.textAfterCursor ?? "" ) guard text.trimmingCharacters(in: .whitespacesAndNewlines).count >= minimumCharacters else { continue } - let bundleIdentifier = row.appBundleIdentifier ?? "unknown" + let bundleIdentifier = normalizedBundleIdentifier(row.appBundleIdentifier) let createdAt = parseDate(row.createdAt) ?? Date(timeIntervalSince1970: 0) let record = TypingHistoryRecord( id: UUID(), @@ -82,6 +82,16 @@ nonisolated enum CotypistExportImporter { return kept } + /// Placeholder for rows Cotypist could not attribute to an app ("unknown.bundle" in its export). + static let unknownBundleIdentifier = "unknown" + + private static func normalizedBundleIdentifier(_ identifier: String?) -> String { + guard let identifier = identifier?.trimmingCharacters(in: .whitespaces), !identifier.isEmpty, + identifier != "unknown.bundle", identifier != unknownBundleIdentifier + else { return unknownBundleIdentifier } + return identifier + } + /// Cotypist uses "-" for fields with no site; treat that like no domain at all. private static func normalizedDomain(_ domain: String?) -> String? { guard let domain = domain?.trimmingCharacters(in: .whitespaces), !domain.isEmpty, domain != "-" else { diff --git a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift index 3b63faa1..2e2f1471 100644 --- a/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/Prompting/BaseCompletionPromptRenderer.swift @@ -149,18 +149,33 @@ enum BaseCompletionPromptRenderer { /// writes. Sits after the stable preface and before the per-keystroke clipboard and screen /// sections: the examples change only every few words (`TypingHistoryQuery.stableText`), so /// placing them earlier keeps more of the prompt's head reusable from the KV cache. + /// + /// All or nothing, like the "following" section: a budget-trimmed history section could end + /// on an unclosed quote, and the model would then read the live caret text as part of that + /// quote. Examples are dropped whole until the section fits its cap. private static func historySection(_ examples: [String]) -> PromptSection? { - let quoted = examples - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { "“\($0)”" } - guard !quoted.isEmpty else { return nil } - return contextSection( - "history", "Earlier writing by the same author:\n" + quoted.joined(separator: "\n"), - priority: 38, maxChars: 760 + let heading = "Earlier writing by the same author:" + var content = heading + for example in examples { + let trimmed = example.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let line = "\n“\(trimmed)”" + guard content.count + line.count <= historyMaxCharacters else { continue } + content += line + } + guard content.count > heading.count else { return nil } + return PromptSection( + name: "history", + content: content, + priority: 38, + minChars: content.count, + maxChars: content.count, + truncation: .preserveStart ) } + private static let historyMaxCharacters = 760 + private static func contextSection( _ name: String, _ content: String, diff --git a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift index d51db69f..319b2fbc 100644 --- a/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift +++ b/Cotabby/UI/Settings/Panes/TypingHistorySectionView.swift @@ -49,7 +49,7 @@ struct TypingHistorySectionView: View { Button("Import Cotypist Export…") { chooseExportToImport() } .disabled(store.status != .ready || store.isImporting) Button("Delete All…", role: .destructive) { isConfirmingDeleteAll = true } - .disabled(store.recordCount == 0) + .disabled(store.recordCount == 0 || store.isImporting) } } label: { VStack(alignment: .leading, spacing: 2) { diff --git a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift index 60e104e5..be008fda 100644 --- a/CotabbyTests/Services/History/TypingHistoryStoreTests.swift +++ b/CotabbyTests/Services/History/TypingHistoryStoreTests.swift @@ -241,4 +241,92 @@ final class TypingHistoryStoreTests: XCTestCase { XCTAssertEqual(store.recordCount, 0) XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) } + + // MARK: - Review fixes + + func test_aSaveCapturedBeforeDeleteAllIsNeverWritten() throws { + let vault = makeVault() + let writer = TypingHistoryWriter(vault: vault) + let record = TypingHistoryRecord( + id: UUID(), bundleIdentifier: "com.apple.mail", domain: nil, createdAt: Date(), updatedAt: Date(), + text: "Deleted history must stay deleted.", source: .recorded + ) + + try writer.destroy(generation: 1) + try writer.save([record], generation: 0, sequence: 1) + + XCTAssertFalse(FileManager.default.fileExists(atPath: vault.fileURL.path)) + } + + func test_anOlderSnapshotCannotOverwriteANewerOne() throws { + let vault = makeVault() + let writer = TypingHistoryWriter(vault: vault) + func record(_ text: String) -> TypingHistoryRecord { + TypingHistoryRecord(id: UUID(), bundleIdentifier: "a", domain: nil, createdAt: Date(), updatedAt: Date(), + text: text, source: .recorded) + } + + try writer.save([record("newer snapshot text")], generation: 0, sequence: 2) + try writer.save([record("older snapshot text")], generation: 0, sequence: 1) + + XCTAssertEqual(try vault.load().map(\.text), ["newer snapshot text"]) + } + + private func focus(_ text: String, element: String, sequence: UInt64, app: String = "com.apple.mail", + isIntegratedTerminal: Bool = false) -> FocusSnapshot { + let input = CotabbyTestFixtures.focusedInputSnapshot( + bundleIdentifier: app, elementIdentifier: element, precedingText: text, + isIntegratedTerminal: isIntegratedTerminal, focusChangeSequence: sequence + ) + return FocusSnapshot(applicationName: "Mail", bundleIdentifier: app, capability: .supported, context: input) + } + + func test_returningToAFieldContinuesItsRecord() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { true } + store.observe(focus("", element: "search", sequence: 2)) { true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.", element: "body", sequence: 3)) { true } + store.observe(focus("", element: "search", sequence: 4)) { true } + + XCTAssertEqual(store.recordCount, 1) + } + + func test_aReusedElementWithDifferentTextStartsANewRecord() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("Hi Arnaud, the Imperum POC is ready for review.", element: "body", sequence: 1)) { true } + store.observe(focus("", element: "other", sequence: 2)) { true } + store.observe(focus("A completely different message about lunch plans today.", element: "body", sequence: 3)) { true } + store.observe(focus("", element: "other", sequence: 4)) { true } + + XCTAssertEqual(store.recordCount, 2) + } + + func test_terminalsAreNeverRecorded() { + let store = makeStore() + store.setRecording(true) + + store.observe(focus("export OPENAI_API_KEY and run the deploy script", element: "t", sequence: 1, + app: "com.googlecode.iterm2")) { true } + store.observe(focus("git push origin main and then open the pull request", element: "vs", sequence: 2, + app: "com.microsoft.VSCode", isIntegratedTerminal: true)) { true } + store.observe(focus("", element: "x", sequence: 3)) { true } + + XCTAssertEqual(store.recordCount, 0) + } + + func test_unchangedTextDoesNotEvaluateTheSettingsGate() { + let store = makeStore() + store.setRecording(true) + var gateCalls = 0 + + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { gateCalls += 1; return true } + store.observe(focus("Hi Arnaud, the Imperum POC is ready", element: "body", sequence: 1)) { gateCalls += 1; return true } + + XCTAssertEqual(gateCalls, 1) + } + } diff --git a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift index f7cf56fb..8d91f575 100644 --- a/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift +++ b/CotabbyTests/Services/Runtime/TypingHistoryPhraseEngineTests.swift @@ -76,6 +76,18 @@ final class TypingHistoryPromptTests: XCTestCase { XCTAssertTrue(prompt.hasSuffix("The POC is"), "The caret text must stay last") } + func test_historySectionIsDroppedWholeRatherThanCutMidQuote() { + let prompt = BaseCompletionPromptRenderer.prompt( + prefixText: String(repeating: "word ", count: 30), + applicationName: "Mail", + userName: nil, + historyExamples: [String(repeating: "earlier ", count: 40)], + contextBudget: 200 + ) + + XCTAssertFalse(prompt.contains("“"), "A partly kept history section would leave an unclosed quote") + } + func test_basePromptWithoutExamplesIsUnchanged() { XCTAssertEqual( BaseCompletionPromptRenderer.prompt(prefixText: "The POC is", applicationName: "Mail", userName: nil),