diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 4b3e274a..6d204c23 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 01A059CD42ADA031B4EB2D8F /* BrowserDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952BA2A01E31BC4E74B7736C /* BrowserDomainTests.swift */; }; 01CDBE7607A9F9F85F87AFF0 /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF5B128907702161F59D236 /* PerformanceMetricsStoreTests.swift */; }; 021084DE49BDB797C67EA100 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A520C43E88BA7D3CCF0E961 /* SuggestionTextNormalizerTests.swift */; }; + 0224B3567B9DC45BD731A7DC /* SuggestionStaleAcceptanceEchoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45DC0822E12D5700AE705E76 /* SuggestionStaleAcceptanceEchoTests.swift */; }; 02A837BD3861FD72B3E99D0F /* BaseCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612CA2988BF19FAE0114165A /* BaseCompletionPromptRendererTests.swift */; }; 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; 02ECD5C9C5FE7B4EB62ABCAF /* FileLogHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57BCCAF434D44772948B696 /* FileLogHandlerTests.swift */; }; @@ -33,7 +34,6 @@ 085C64A7C11A728CFA2A6D57 /* WelcomePersonalizeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */; }; 089B6C0BD99F2B6C4FF9D7EC /* SpellingDictionaryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FEDE8C3BE5E3C5E26989AE /* SpellingDictionaryPicker.swift */; }; 08A76C831813F9D5CEC578E0 /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; - 08EF35590D0C64D0D3FBACFE /* SuggestionSessionReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1F9C5D85651740CC51B47F /* SuggestionSessionReconcilerTests.swift */; }; 094DE1CA4A821D722F173F9D /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; 09B092E8A682D127FC9872A7 /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6D4D7CAD20C94DF9EF21D2 /* HuggingFaceAPIClient.swift */; }; 0A08A88B773845EF9A27DF0F /* SymSpellCorrector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */; }; @@ -42,6 +42,7 @@ 0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */; }; 0A4528A5D6D83BD0D8A56854 /* EmojiSynonymCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38190E9916A96736E6769C2 /* EmojiSynonymCatalog.swift */; }; 0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */; }; + 0B57FA3ACA73DD2A1858BC01 /* SuggestionOverlayAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917648CBC418C0D485BBF88F /* SuggestionOverlayAcceptanceTests.swift */; }; 0B7C1A4F515F63462CCCE9EA /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6D4D7CAD20C94DF9EF21D2 /* HuggingFaceAPIClient.swift */; }; 0C0087684909C4A4F067BA9B /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; 0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; @@ -61,6 +62,7 @@ 1415DED5362F6F343AF63123 /* ControlTokenMarkers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33D4F378E5C70CAF7827A90A /* ControlTokenMarkers.swift */; }; 1419979A0649B00DACF75C61 /* OCRTextHygieneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B0584FA16465453F04CA8C /* OCRTextHygieneTests.swift */; }; 14611368270D611A2D5DC67E /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */; }; + 1495BA844DB58A0DE69A1E6F /* SuggestionClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6227F413EF448427888881 /* SuggestionClientError.swift */; }; 14AAE7F7E381CE011501121D /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3096225612BC53296FB6931 /* DisplayCoordinateConverter.swift */; }; 14AB92F33FAF52D2BCE589FB /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E6C2EBE2B63C2DF3CC4073 /* LanguageTagsEditor.swift */; }; 1536BBBDFC274C414DEB1420 /* WebContentFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 601C120D3DB41535E1D90E24 /* WebContentFieldDetectorTests.swift */; }; @@ -80,9 +82,11 @@ 1A3C48417F284B8DA55F4FEC /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E6C2EBE2B63C2DF3CC4073 /* LanguageTagsEditor.swift */; }; 1A4A2FCA8F08258A8A70A057 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 1AB037C6DC95769F4765B5CA /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */; }; + 1ABF3AC6E9EAACB537F23A74 /* SuggestionPresentationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300472C842A2FA08A098BC9E /* SuggestionPresentationModels.swift */; }; 1B66CA97E53D1ADB9F4E9A4A /* InputMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB36177FC6C26F5E4CD1AD6E /* InputMonitorTests.swift */; }; 1B80604D77F1AF1DF96DDD45 /* MarkerSelectionSynthesizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7E66CFA5AB733CE1F793A8 /* MarkerSelectionSynthesizerTests.swift */; }; 1C6B2EBC77B508A3FF43E994 /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC8F0BAC452D20743D316FB6 /* FocusSnapshotResolverLiveTests.swift */; }; + 1CCDD5419039F1151AF7A7B9 /* SuggestionOverlayViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */; }; 1D5389E9562AF6315BFCDCE1 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; 1D54C941ED086C6427CFD773 /* MacroEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974A8708D2006767BD76862A /* MacroEngine.swift */; }; 1EB5B904DB17460D839AD57F /* SpellingLanguageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CCBF33813CE5CD3B9EA37B /* SpellingLanguageResolver.swift */; }; @@ -91,6 +95,7 @@ 1F12149EA676E69B75BDBFDA /* SuggestionAvailabilityEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADD165EBA2FE4C66F5BA885 /* SuggestionAvailabilityEvaluator.swift */; }; 1F8CC88AFFE67C08944CF506 /* WindowScreenshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */; }; 1FEB7ADDE569DE8C6E13920B /* GPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */; }; + 206D655F360EE8590ACD4CC3 /* EngineAndModelPaneView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF56EDA9A873F7D75D7D81 /* EngineAndModelPaneView+Actions.swift */; }; 2089CC6E33B24D5CD3DD1E57 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F324C645A4E53FB6E90061A /* DownloadOutcomeClassifier.swift */; }; 2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */; }; 21187878C7390AD639717B8F /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */; }; @@ -123,7 +128,10 @@ 28D217A96946A2005FCBEBFD /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; 29E20FF0F413E723D2A526C4 /* DecodeStopPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C319B4FF652512A69377DD5C /* DecodeStopPolicy.swift */; }; 2A2C0AA222A33A8B43880661 /* OpenAICompatibleAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A47949D77BB2E411F1B81E5 /* OpenAICompatibleAPIClientTests.swift */; }; + 2A7F62CE6415B5C5A4FC98E4 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6481CD84E28FE280775BA9F /* ActiveSuggestionSession.swift */; }; 2AF12199E0FD9B802BF35A10 /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D41CC179EC04093971A0D0 /* SpellingDictionaryCatalog.swift */; }; + 2AF6D229913AA599FFB7A064 /* EngineAndModelPaneView+OpenSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE08CD86E77A27E6BB7089B /* EngineAndModelPaneView+OpenSource.swift */; }; + 2B18563E8907DE21A6BDC865 /* EngineAndModelPaneView+AppleIntelligence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34994266A9A9C8C525E4DEC9 /* EngineAndModelPaneView+AppleIntelligence.swift */; }; 2B355C61C20866199F414EB9 /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */; }; 2B4049F81730E41A1274E57F /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */; }; 2C44C5142ED914BF99A70EC6 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; @@ -164,6 +172,7 @@ 3935AEF7D139557677D09318 /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF96999230079A768BEFD0FC /* KeycapView.swift */; }; 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; 3963B60499D83D9363E401BE /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61103A4FEA699AAE0E61D89B /* InsertedTextAdvance.swift */; }; + 397F1903F7C4B29F4BEC3FF2 /* SuggestionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114AC8D1D904F49DEFDAC0C6 /* SuggestionConfiguration.swift */; }; 39836F8D77DA6A4B3E3426FE /* GPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */; }; 398D5648DD9DB75EDB5B9C08 /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880D641ED480016420D007A0 /* EmojiVariantResolver.swift */; }; 39910B6812663A61FC13D6BD /* HuggingFaceSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF8401D3859F00CC94F8893 /* HuggingFaceSearchService.swift */; }; @@ -206,6 +215,7 @@ 4666D3B3A81CCB4CC68FC395 /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B778713FE1C9481CE0DF065 /* FocusModels.swift */; }; 468477E87700B5D18BCD7A7A /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB1A3F7D6C0FF28717E6D4C /* SuggestionEngineModels.swift */; }; 469626C2EFEDFD6C188941F5 /* de.txt in Resources */ = {isa = PBXBuildFile; fileRef = C648EBB10D7F8E0B904DEC91 /* de.txt */; }; + 46A7E7D70C4681B2BCCC4F48 /* EngineAndModelPaneView+OpenSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE08CD86E77A27E6BB7089B /* EngineAndModelPaneView+OpenSource.swift */; }; 46C96D63773ED90E74588315 /* SentenceBoundaryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C231B65BAF20AD33A3C0130C /* SentenceBoundaryClassifier.swift */; }; 47123A894E2866177AA9A761 /* OnboardingFlowSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AEF94A48DF96D6BEE3048C /* OnboardingFlowSteps.swift */; }; 47D099DE9608A174BB0C3C3F /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771EAA74003DAD7D4F3FD199 /* OnboardingTemplateFeatureListTests.swift */; }; @@ -213,6 +223,7 @@ 484766367056A7103131FAEA /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FD5822D3A75266B20A50CE /* SuggestionPauseModels.swift */; }; 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; }; + 48CBD4789B020D1CEAB3F73E /* EngineAndModelPaneView+AppleIntelligence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34994266A9A9C8C525E4DEC9 /* EngineAndModelPaneView+AppleIntelligence.swift */; }; 48CE539CB1D41EEE7B9F0819 /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800ADA19752797F5704D9B72 /* TrailingDuplicationFilterTests.swift */; }; 49AD09B2EA8752F20A427F8E /* LlamaSuggestionEngineCancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5067AE83AEE957322A47E60 /* LlamaSuggestionEngineCancellationTests.swift */; }; 4A2AC346A5A857AF551D1598 /* SuggestionPauseModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAED667FCDDF3DE1A7EBDDB9 /* SuggestionPauseModelsTests.swift */; }; @@ -229,6 +240,7 @@ 4E2DEFF3CA51E8B160B802CA /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */; }; 4E8268A5C44B9E5945EBE59D /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7A9C39A0713BD3B442C9AC /* AppUpdateManager.swift */; }; + 4EAE973B139CF9192F49EE03 /* EngineAndModelPaneView+Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9C51ED6D92911B0365F3E4 /* EngineAndModelPaneView+Endpoint.swift */; }; 4F3CE71B97663B8025779F9C /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; 50CDF4E6CD754A74DDCF67FC /* MacroTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CDB11D96F343194B390321 /* MacroTriggerStateMachine.swift */; }; @@ -237,6 +249,7 @@ 5161D2D6BE6ADEAC91A7F0D4 /* MacroReferenceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */; }; 51F0B064F34BA3F88196C39F /* CalendarAccessibilityCapturePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE7D19D22434E3E24804999 /* CalendarAccessibilityCapturePolicy.swift */; }; 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; + 5303882C64971F1D5B477091 /* FocusedInputContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2692314CF62E130DD560B1A /* FocusedInputContext.swift */; }; 53C1D96427CECC559FAC631A /* EmojiTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD72ADA2F88BD3C7E56CD8EC /* EmojiTriggerStateMachineTests.swift */; }; 53F946FF09038C1C751C6E53 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 54091F97C3F64ABB737DC76B /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */; }; @@ -259,10 +272,12 @@ 5CB91F89B0E4ACA351621C51 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */; }; 5CBE09685D85DFED89B79437 /* FocusPollBackoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1337F5C94FA9A6388841805 /* FocusPollBackoffTests.swift */; }; 5CF34E01F13FB242817057E2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6F134BAB5BC7015B9B2354AF /* Sparkle */; }; + 5D56B30A42CC2669E204E117 /* FocusSnapshotExternalApplicationIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF1F8FDB53538819537DB55 /* FocusSnapshotExternalApplicationIdentityTests.swift */; }; 5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */; }; 5DF60DD4F15079F1C1855495 /* EmojiQueryRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D11446FF576F0BBDEE2D2F /* EmojiQueryRun.swift */; }; 5E1CE9C9062D2DEB4B96FD5C /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F49C55FFF4352A6763BAE1A /* PerDomainDisableSettings.swift */; }; 5E43AD2C53EA1813A6A138E3 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFEDED24AED136A9C83F730 /* SuggestionSettingsModel.swift */; }; + 5E599A4BDF1B93A9711EF487 /* EngineAndModelPaneView+Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9C51ED6D92911B0365F3E4 /* EngineAndModelPaneView+Endpoint.swift */; }; 5EA4B893AE7C634C0F9734B2 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95EF4BBE7320021CB8CD54D9 /* ConfidenceSuppressionPolicy.swift */; }; 5ED4272F22F0FB0DF2AE0AE1 /* MPL-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4E283DF8948B10268B46811F /* MPL-2.0.txt */; }; 5F3E299F27F63F8F9D9FBABF /* SpellingLanguageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61CCBF33813CE5CD3B9EA37B /* SpellingLanguageResolver.swift */; }; @@ -272,6 +287,7 @@ 60636D92D12FED132250D8D2 /* PerformancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */; }; 60773158D86A0D2F0EB3FF34 /* SettingsQuickLinkCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */; }; 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; + 6139DA1889A939120969FD7A /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34784852DCFDF959F76C7F78 /* SuggestionSettingsModelDisabledAppsTests.swift */; }; 613C60F5D664655CB4D8CC27 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */; }; 6167731C22B9B7ECFC5595AA /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7786D3C52CC72971B74657F /* EmojiRecents.swift */; }; 61932DE224F103A22C8E4EF7 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; @@ -283,6 +299,7 @@ 63AEBAB1CDA1DEDB80F97F3F /* SpellingDictionaryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FEDE8C3BE5E3C5E26989AE /* SpellingDictionaryPicker.swift */; }; 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; 64CC33F721B377F4F0547685 /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 330D425B2BF2245FD7BBE4FA /* SuggestionRequestFactory.swift */; }; + 6573CD78E36EF0C297CF291A /* SuggestionWordAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0523515C5DAAED9475BB8A3 /* SuggestionWordAcceptanceTests.swift */; }; 65B44060371596BFA0EFD987 /* HardwareCapabilityProbeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FE28720A0C1B74B5DEAA58 /* HardwareCapabilityProbeTests.swift */; }; 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF2889446A21BC8AC773A81 /* CotabbyTestFixtures.swift */; }; 66A697ACC03A8A7338AB2204 /* WebContentFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178C5C54E1CC7E906DEA1D51 /* WebContentFieldDetector.swift */; }; @@ -303,6 +320,7 @@ 6A8454A989104AE150308BCF /* it-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2D8AA55C2B730110E8598F91 /* it-100k.txt */; }; 6AF17537A3A91BAA875DEA0F /* HuggingFaceSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF8401D3859F00CC94F8893 /* HuggingFaceSearchService.swift */; }; 6B59FBD73BA169121537D3DE /* DeepGeometryWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F076BC5527D08E5CE55E3A2C /* DeepGeometryWalkThrottleTests.swift */; }; + 6B8240F5A193C1385524063B /* SuggestionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2574E89992AA55C237DE995 /* SuggestionRequest.swift */; }; 6B9B1630E1F30EAE429B5B60 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F324C645A4E53FB6E90061A /* DownloadOutcomeClassifier.swift */; }; 6CB0A928230ACD262B02CFDF /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */; }; @@ -319,6 +337,7 @@ 702A007D1950AC98DA2090BB /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E36DC1471E2D627F4735A0C /* SystemResourceSampler.swift */; }; 70998EEDBB8B9953CB043991 /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */; }; 70A40CE02EF0AF361263BE7E /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */; }; + 70F4E4A0D9471C43699FD708 /* FocusedInputContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2692314CF62E130DD560B1A /* FocusedInputContext.swift */; }; 715BD5081873EF5FF070D3B0 /* CalendarAccessibilityCapturePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 863B5A1DC3C4B9668F620245 /* CalendarAccessibilityCapturePolicyTests.swift */; }; 71BB14A043A34851121AF2CE /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B67EF101326F317EFD670D /* ApplicationBundleMetadata.swift */; }; 71DA26FE8F6758CAFE6F7042 /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8736D4321E8BF847D1F7338E /* EmojiPickerModels.swift */; }; @@ -329,6 +348,8 @@ 7291020840F2164EBB764FAF /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */; }; + 73E51046737DD72C1168927C /* SuggestionInsertionChunkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A561EFA8C48EC0C8E9753B9B /* SuggestionInsertionChunkTests.swift */; }; + 73E981A8A491BED1A920A323 /* SuggestionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650CC43EEE4F50A91CAF83B2 /* SuggestionResult.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74EE4B6CFAD07099867BD0FC /* SpellingLanguageResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71686BC29B7E0FACFF0B19C /* SpellingLanguageResolverTests.swift */; }; @@ -345,6 +366,7 @@ 77C40ACA872F20CFAB2BDD7F /* TagChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04DFC5BD7821D73DA21D7DB /* TagChip.swift */; }; 7807D6217618D982CD301875 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */; }; 78F319805DCEA8B1239C473A /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3096225612BC53296FB6931 /* DisplayCoordinateConverter.swift */; }; + 78F4CBAD2B7580A29F0F07C5 /* SuggestionSessionReconciliationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F388D480F655DF7D8177661E /* SuggestionSessionReconciliationTests.swift */; }; 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 793E33BA3A7F5B706D18FC01 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACAD48CDC2F27F49558473F1 /* CompletionRenderModePolicy.swift */; }; 7955ED6ECA506D4EDA204BB1 /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7A9C39A0713BD3B442C9AC /* AppUpdateManager.swift */; }; @@ -358,6 +380,7 @@ 7D62632C0775EB7E5D6DAE53 /* InsertionSafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E30CEB74593771E5F62E5E /* InsertionSafetyGateTests.swift */; }; 7E11BEAD32E9FF170C92000A /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696204AAEBA357129F77646 /* HuggingFaceModelBrowserView.swift */; }; 7E99F5676A1D1DF7EA7D7702 /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; + 7F015A80AE53B985AD83D149 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = A414F29774C2D28E07D89667 /* EngineAndModelPaneView+Power.swift */; }; 7FC103944F4EF39DB965F469 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = 88921938DC814625ED57D605 /* InMemoryLogging */; }; 8014C090E6B95CC1CE40230A /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D080DC6ABFA205A22E3D5FE /* FocusCapabilityFlickerGateTests.swift */; }; 80624558CD11E3275F1B6250 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62CBDE1462C7733E4228BBE /* SuggestionFadeInPolicy.swift */; }; @@ -391,6 +414,8 @@ 8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */; }; 8B36A3FDDBB803C72A1E521B /* PerDomainDisableSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A306C7B2B0790A0B98D8F353 /* PerDomainDisableSettingsTests.swift */; }; 8BC2FD8031EDE67956BCAC73 /* EmojiSynonymCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38190E9916A96736E6769C2 /* EmojiSynonymCatalog.swift */; }; + 8BD55AF37623F9901A3A39DF /* SuggestionPresentationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300472C842A2FA08A098BC9E /* SuggestionPresentationModels.swift */; }; + 8BE1423F4375CEE6481771D0 /* SuggestionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650CC43EEE4F50A91CAF83B2 /* SuggestionResult.swift */; }; 8C090BA472D90D9682F329BA /* KeyCodeLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF4D6CEDF032A25EF72D7228 /* KeyCodeLabels.swift */; }; 8CC26E987D538732AE22DDA4 /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB49ACD7DA4F098B2428C6 /* EmojiRecentsTests.swift */; }; 8D63900E61EAA983D2C726FC /* SuggestionOverlayStabilityGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8711876D1C2789D34601E159 /* SuggestionOverlayStabilityGateTests.swift */; }; @@ -431,8 +456,10 @@ 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 980E02175E2D83D4B60FE519 /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */; }; 985C583A41DEEE040F48D661 /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B778713FE1C9481CE0DF065 /* FocusModels.swift */; }; + 98663EEE0FEA986130B55A25 /* SuggestionPhraseAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEEAB65651DCA24AFD7E38 /* SuggestionPhraseAcceptanceTests.swift */; }; 986C1339A8FBA3569FC129D6 /* PopupChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C6335EED41343595F17A08 /* PopupChrome.swift */; }; 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9331B12568E717FCF9A5086 /* CurrentWordExtractorTests.swift */; }; + 996786038E31C671B650DFE4 /* SuggestionOverlayViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */; }; 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 997AD1EDD598F4239EA7A2A1 /* CaretRunPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB7940B64F4B82E03AB674E /* CaretRunPlacementTests.swift */; }; 9A17FE331C20BB85B65457DD /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8772CBA9365F890D03A56995 /* AppSurfaceClassifier.swift */; }; @@ -499,6 +526,7 @@ AFEB6885A317284467CD44ED /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FD5822D3A75266B20A50CE /* SuggestionPauseModels.swift */; }; B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; + B10CB60110995AB9F402A363 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6481CD84E28FE280775BA9F /* ActiveSuggestionSession.swift */; }; B1E93ED4D7C0B9BB77F95ADC /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; B2994532783F7199B1E84A61 /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E36DC1471E2D627F4735A0C /* SystemResourceSampler.swift */; }; B2F78CEF3A83C191E72214D4 /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C91940E688482C3F80423 /* SuggestionSessionReconciler.swift */; }; @@ -506,6 +534,7 @@ B422256933F7BEAEF2FC4176 /* es-100l.txt in Resources */ = {isa = PBXBuildFile; fileRef = 620D393D3B7E687A08FA9446 /* es-100l.txt */; }; B4D36F5D03E3143CE74582F9 /* AppearancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */; }; B50EDCA5C4C5FE4FC548AA74 /* StaticTextRunWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */; }; + B52EA9A632976BCED9693E1B /* SuggestionClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6227F413EF448427888881 /* SuggestionClientError.swift */; }; B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; }; B57DBA2359D4D666E20BDDBD /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACAD48CDC2F27F49558473F1 /* CompletionRenderModePolicy.swift */; }; B588C09233E69C6EDC69BEDC /* LlamaSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */; }; @@ -516,10 +545,10 @@ B6815BBBC91809A6EAA914CB /* UnitConversionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E7794DF60664B1FA8F6E7B /* UnitConversionEvaluator.swift */; }; B6B82B72D4AE1ABFFA78978F /* SuggestionCaretLayoutRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE87C835317AF47C007C6B6 /* SuggestionCaretLayoutRepairTests.swift */; }; B6BA7DF77DCA55F9EF090AEE /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; + B6D05E46AA2ADDD00CCC888B /* EngineAndModelPaneView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF56EDA9A873F7D75D7D81 /* EngineAndModelPaneView+Actions.swift */; }; B71A95428B4003A29685FD5D /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */; }; B782EC08B7516791BDB21172 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */; }; B81FF9FB9A794656F63DEC01 /* SettingsIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 865F7C0490DAF7D77523BE84 /* SettingsIndexTests.swift */; }; - B8C71E65399B88D5543644DE /* SuggestionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977CE0DE2F43D3F964C0EC73 /* SuggestionModels.swift */; }; B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; B99A9C90F3688BEFD28513AD /* SuggestionCoordinatorPredictionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA86BBD7476CFAA0BB6A1C55 /* SuggestionCoordinatorPredictionTests.swift */; }; B9D060250BDC10C5FD05B70B /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC31A792E63C132BE8ABDFD2 /* EmojiMatcher.swift */; }; @@ -552,6 +581,7 @@ C1ADF50B2D3A3A05CD0FFA68 /* LlamaEvalScoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202086735E28B96BBCE55E6B /* LlamaEvalScoring.swift */; }; C2CD418FED8EB332C6417AF9 /* TokenCountEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A502BFB13373D1931B082CD /* TokenCountEstimator.swift */; }; C3A3977AF84C7037BE1AD17E /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661AAB8CC3ABDA49A957864 /* TextDirectionDetectorTests.swift */; }; + C3A927EB44C650FCF622C58E /* SuggestionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114AC8D1D904F49DEFDAC0C6 /* SuggestionConfiguration.swift */; }; C3C347E9485B70C59BF5EFC8 /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */; }; C3F63C89E82ED82DF24BD603 /* TypoGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */; }; C4805FA1A6CD552E572D7012 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = C42F41C6497D6199DA27D6CD /* InMemoryLogging */; }; @@ -596,6 +626,7 @@ D2BB9B47E0E1619021931B49 /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; }; D2DB4238276DD73A8D5F588C /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BF37C1A0F734BB940E371C2 /* ClipboardRelevanceFilter.swift */; }; D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */; }; + D397D3FF36E9AF8AD8421275 /* SuggestionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2574E89992AA55C237DE995 /* SuggestionRequest.swift */; }; D42BBBD704B21898047615E0 /* LlamaRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16967F782C95CEB09B9C8D49 /* LlamaRuntimeModels.swift */; }; D4460806E35A0FABE6CBD339 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC31A792E63C132BE8ABDFD2 /* EmojiMatcher.swift */; }; D4DD6B6D22598BB3B98792DA /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; @@ -629,11 +660,13 @@ DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */; }; DE5879DCB3223CF67C9F399A /* MacroController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4638C74239D1DE2DC4D87975 /* MacroController.swift */; }; DED8731A5A34A10349AA05A8 /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8736D4321E8BF847D1F7338E /* EmojiPickerModels.swift */; }; + DF1AE8F318CE582ED675CD7F /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = A414F29774C2D28E07D89667 /* EngineAndModelPaneView+Power.swift */; }; DFC02F29B2F44DA8176F7EEF /* GhostTextColorPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786A5B721D148D09B5BDE651 /* GhostTextColorPreset.swift */; }; E0DAB3CDE782C330AF21FC0D /* DateMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */; }; E17CAA453B1F534D284F0D89 /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; }; E2E830C1D5FEFE5F621018FE /* EmojiUsageModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C4773B9316036F7E594EE1 /* EmojiUsageModels.swift */; }; E313639E71AE1374D2B9A956 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; }; + E39A5FA3F8CE7BF6168A073C /* SuggestionSessionTypingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 356C8E766609ED3CC1501F24 /* SuggestionSessionTypingTests.swift */; }; E3A27C2FDF6405B926765D21 /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 085D4F4D5242B0E7AD506473 /* DebouncePolicy.swift */; }; E3CF6ADB3DFAE5AB2F23935B /* SuggestionSettingsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A914A6C81B1824E4907ACADE /* SuggestionSettingsData.swift */; }; E42469F1225220932384F342 /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2229037BCDF7EFD3D47C15F /* ClipboardContentDistiller.swift */; }; @@ -699,7 +732,6 @@ FAEC5301B36B92E4B4A169D3 /* RuntimeBootstrapModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72CC98CFDA795F8210198E8 /* RuntimeBootstrapModelTests.swift */; }; FB0E2CE46002270A254E5FB3 /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */; }; FB31D4954D031D97EDBB4C73 /* SuggestionSettingsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A914A6C81B1824E4907ACADE /* SuggestionSettingsData.swift */; }; - FC537C9D5B0D5A61E6A7C4AA /* SuggestionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977CE0DE2F43D3F964C0EC73 /* SuggestionModels.swift */; }; FC7B332AC1F0BF42689D6EE8 /* CompletionSeamGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3806EA97A628F15F155262D5 /* CompletionSeamGuard.swift */; }; FCA314FBD31BF7C2CD273321 /* EmojiQueryRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D11446FF576F0BBDEE2D2F /* EmojiQueryRun.swift */; }; FCB060F562093AACA90CAA21 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FD2044CDEA95EC02C9B2FE /* MarkerSelectionSynthesizer.swift */; }; @@ -744,6 +776,7 @@ 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; + 114AC8D1D904F49DEFDAC0C6 /* SuggestionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionConfiguration.swift; sourceTree = ""; }; 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrector.swift; sourceTree = ""; }; 12F6FDB6D09851CC5C6649EF /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = ""; }; 13838E1372956FF2CFC5ECCC /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; @@ -773,6 +806,7 @@ 1F70588152A9E06C46971ACC /* CompletionSeamGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuardTests.swift; sourceTree = ""; }; 1F7ED2D9D71CFAD9A30977E9 /* ArithmeticEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluator.swift; sourceTree = ""; }; 1FB0AF4FEC5A3FD07652F718 /* CotabbyDebugOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptionsTests.swift; sourceTree = ""; }; + 1FBEEAB65651DCA24AFD7E38 /* SuggestionPhraseAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPhraseAcceptanceTests.swift; sourceTree = ""; }; 202086735E28B96BBCE55E6B /* LlamaEvalScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaEvalScoring.swift; sourceTree = ""; }; 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; 2228A6C24CDE5F9D8DE33BF3 /* FoundationModelDriftEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelDriftEvalTests.swift; sourceTree = ""; }; @@ -792,6 +826,7 @@ 2D8AA55C2B730110E8598F91 /* it-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "it-100k.txt"; sourceTree = ""; }; 2E379AF08CDFF65D6EFC565E /* ActivationIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivationIndicatorController.swift; sourceTree = ""; }; 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; + 300472C842A2FA08A098BC9E /* SuggestionPresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPresentationModels.swift; sourceTree = ""; }; 309B45D197958D1DA2FE4D53 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 310F3498A3E498729CC8433E /* MidWordContinuationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MidWordContinuationPolicy.swift; sourceTree = ""; }; 31A62CE4DA3E55B5E117DBD8 /* LlamaSuggestionEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEvalTests.swift; sourceTree = ""; }; @@ -802,7 +837,10 @@ 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; 33D4F378E5C70CAF7827A90A /* ControlTokenMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkers.swift; sourceTree = ""; }; 341A1BBA636AEBA2491F6A26 /* FocusTrackingModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTrackingModelTests.swift; sourceTree = ""; }; + 34784852DCFDF959F76C7F78 /* SuggestionSettingsModelDisabledAppsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelDisabledAppsTests.swift; sourceTree = ""; }; + 34994266A9A9C8C525E4DEC9 /* EngineAndModelPaneView+AppleIntelligence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+AppleIntelligence.swift"; sourceTree = ""; }; 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsIndex.swift; sourceTree = ""; }; + 356C8E766609ED3CC1501F24 /* SuggestionSessionTypingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionTypingTests.swift; sourceTree = ""; }; 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; 36652DB88C5948AA4A31524A /* ModelFileValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidator.swift; sourceTree = ""; }; 3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; @@ -830,6 +868,7 @@ 422E236CC16508B664E40F1E /* CaretLinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePosition.swift; sourceTree = ""; }; 4275FD6213DCDA953B4800EA /* SpeculativeAcceptanceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeculativeAcceptanceContext.swift; sourceTree = ""; }; 445BDC778CCC14AC909D659D /* ScreenTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractorTests.swift; sourceTree = ""; }; + 45DC0822E12D5700AE705E76 /* SuggestionStaleAcceptanceEchoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStaleAcceptanceEchoTests.swift; sourceTree = ""; }; 45E30CEB74593771E5F62E5E /* InsertionSafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGateTests.swift; sourceTree = ""; }; 4638C74239D1DE2DC4D87975 /* MacroController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroController.swift; sourceTree = ""; }; 46B67EF101326F317EFD670D /* ApplicationBundleMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadata.swift; sourceTree = ""; }; @@ -889,6 +928,7 @@ 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 64AEF94A48DF96D6BEE3048C /* OnboardingFlowSteps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowSteps.swift; sourceTree = ""; }; 64B0584FA16465453F04CA8C /* OCRTextHygieneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRTextHygieneTests.swift; sourceTree = ""; }; + 650CC43EEE4F50A91CAF83B2 /* SuggestionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionResult.swift; sourceTree = ""; }; 652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextComposerTests.swift; sourceTree = ""; }; 66B5FF2D1DCC846AABC134CF /* MenuBarRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicyTests.swift; sourceTree = ""; }; 66CF2A70D4699421AC9BD849 /* NOTICE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTICE.md; sourceTree = ""; }; @@ -931,11 +971,13 @@ 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluator.swift; sourceTree = ""; }; 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; 7B3AD6D4125ED99669F4EC32 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; + 7BE08CD86E77A27E6BB7089B /* EngineAndModelPaneView+OpenSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+OpenSource.swift"; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7CC541A0FCCE9172C24C1350 /* SyntheticReplacePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticReplacePlannerTests.swift; sourceTree = ""; }; 7CC9E0602050256E3B0B08B2 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; 7D2E806C1915514651343055 /* HuggingFaceModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelsTests.swift; sourceTree = ""; }; 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + 7DF1F8FDB53538819537DB55 /* FocusSnapshotExternalApplicationIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotExternalApplicationIdentityTests.swift; sourceTree = ""; }; 7E36DC1471E2D627F4735A0C /* SystemResourceSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResourceSampler.swift; sourceTree = ""; }; 7E4C115072B97CCCE9020F72 /* LlamaPromptCacheHintTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptCacheHintTrackerTests.swift; sourceTree = ""; }; 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSessionScopedCache.swift; sourceTree = ""; }; @@ -948,6 +990,7 @@ 846A1C3328B3B1CB42E2E46B /* EmojiUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStore.swift; sourceTree = ""; }; 8489C1318F0232BFF9F2758C /* TextDirectionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDirectionDetector.swift; sourceTree = ""; }; 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; + 85BF56EDA9A873F7D75D7D81 /* EngineAndModelPaneView+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Actions.swift"; sourceTree = ""; }; 85C36B59551CE66231CB8E26 /* CaretLinePositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePositionTests.swift; sourceTree = ""; }; 85E6C2EBE2B63C2DF3CC4073 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; 863B5A1DC3C4B9668F620245 /* CalendarAccessibilityCapturePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarAccessibilityCapturePolicyTests.swift; sourceTree = ""; }; @@ -966,9 +1009,11 @@ 8AB17696CE79982039061F29 /* SuggestionSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStore.swift; sourceTree = ""; }; 8B4F6E70B8A242F7BDE5361A /* FocusDebugOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusDebugOverlayController.swift; sourceTree = ""; }; 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; + 8C6227F413EF448427888881 /* SuggestionClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionClientError.swift; sourceTree = ""; }; 8CF6B7FFBF77B4290F5F2FB8 /* SurfaceContextCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextCache.swift; sourceTree = ""; }; 8F961F5DF2A392F6F5F94F8A /* SuggestionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinator.swift; sourceTree = ""; }; 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRowLabel.swift; sourceTree = ""; }; + 917648CBC418C0D485BBF88F /* SuggestionOverlayAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayAcceptanceTests.swift; sourceTree = ""; }; 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePreviewPanelController.swift; sourceTree = ""; }; 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFeatureShowcase.swift; sourceTree = ""; }; 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayWindowController.swift; sourceTree = ""; }; @@ -980,8 +1025,8 @@ 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRankerTests.swift; sourceTree = ""; }; 95EF4BBE7320021CB8CD54D9 /* ConfidenceSuppressionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceSuppressionPolicy.swift; sourceTree = ""; }; 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClient.swift; sourceTree = ""; }; + 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayViews.swift; sourceTree = ""; }; 974A8708D2006767BD76862A /* MacroEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngine.swift; sourceTree = ""; }; - 977CE0DE2F43D3F964C0EC73 /* SuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionModels.swift; sourceTree = ""; }; 97AA016AC303DB8FC3F54A16 /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 97F14FC0E8E0A5D23FA2F54C /* KeyCodeLabelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabelsTests.swift; sourceTree = ""; }; 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = frequency_dictionary_en_82_765.txt; sourceTree = ""; }; @@ -1008,7 +1053,9 @@ A306C7B2B0790A0B98D8F353 /* PerDomainDisableSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerDomainDisableSettingsTests.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; + A414F29774C2D28E07D89667 /* EngineAndModelPaneView+Power.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Power.swift"; sourceTree = ""; }; A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeManager.swift; sourceTree = ""; }; + A561EFA8C48EC0C8E9753B9B /* SuggestionInsertionChunkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInsertionChunkTests.swift; sourceTree = ""; }; A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextPreview.swift; sourceTree = ""; }; A62CBDE1462C7733E4228BBE /* SuggestionFadeInPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicy.swift; sourceTree = ""; }; A73AF6E8961EC168FA6DDD2D /* FoundationModelAvailabilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityServiceTests.swift; sourceTree = ""; }; @@ -1060,6 +1107,7 @@ BF924740638E6A6F77FCB4C4 /* LlamaDecodeGateDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaDecodeGateDefaultsTests.swift; sourceTree = ""; }; C1337F5C94FA9A6388841805 /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = ""; }; C231B65BAF20AD33A3C0130C /* SentenceBoundaryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryClassifier.swift; sourceTree = ""; }; + C2692314CF62E130DD560B1A /* FocusedInputContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputContext.swift; sourceTree = ""; }; C2AD7D366EC4344D332EE6A3 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; C319B4FF652512A69377DD5C /* DecodeStopPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicy.swift; sourceTree = ""; }; C379D77029D6E88C8C1B9AF7 /* emoji.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = emoji.json; sourceTree = ""; }; @@ -1072,6 +1120,7 @@ C9C000E46A1E404932F89C81 /* he.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = he.txt; sourceTree = ""; }; C9F9F3C58A0635AAB53D0441 /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInteractionState.swift; sourceTree = ""; }; + CA9C51ED6D92911B0365F3E4 /* EngineAndModelPaneView+Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Endpoint.swift"; sourceTree = ""; }; CB051B5B72986C03AB1EE428 /* MenuBarStatusLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusLabelView.swift; sourceTree = ""; }; CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolver.swift; sourceTree = ""; }; CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryResourceTests.swift; sourceTree = ""; }; @@ -1088,6 +1137,7 @@ D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocator.swift; sourceTree = ""; }; D22CA80189225431804E87B0 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; + D2574E89992AA55C237DE995 /* SuggestionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequest.swift; sourceTree = ""; }; D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.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 = ""; }; @@ -1102,7 +1152,6 @@ D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsPaneView.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 = ""; }; - DA1F9C5D85651740CC51B47F /* SuggestionSessionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconcilerTests.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 = ""; }; @@ -1114,6 +1163,7 @@ DFB7940B64F4B82E03AB674E /* CaretRunPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretRunPlacementTests.swift; sourceTree = ""; }; DFEBE38C24E36D1FA3106FE5 /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; DFFEDED24AED136A9C83F730 /* SuggestionSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModel.swift; sourceTree = ""; }; + E0523515C5DAAED9475BB8A3 /* SuggestionWordAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWordAcceptanceTests.swift; sourceTree = ""; }; E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsIconTile.swift; sourceTree = ""; }; E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextLivePreviewField.swift; sourceTree = ""; }; E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTracker.swift; sourceTree = ""; }; @@ -1123,6 +1173,7 @@ E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeTemplateStepView.swift; sourceTree = ""; }; E4C1DDA236561DF284073F33 /* SuggestionCoordinatorAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorAcceptanceTests.swift; sourceTree = ""; }; E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = ""; }; + E6481CD84E28FE280775BA9F /* ActiveSuggestionSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSuggestionSession.swift; sourceTree = ""; }; E6B7AF08299D919C30ACE82C /* DateMacroEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluatorTests.swift; sourceTree = ""; }; E80071E4A5D844CEF5B7C487 /* RequestIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestIDTests.swift; sourceTree = ""; }; E94A6036F4B703A2FF7F68EF /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; @@ -1142,6 +1193,7 @@ F2C80E780DD9B08317E3B374 /* AppSurfaceClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSurfaceClassifierTests.swift; sourceTree = ""; }; F2F098B80AE83F7973168641 /* ConfidenceSuppressionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceSuppressionPolicyTests.swift; sourceTree = ""; }; F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEdgeIconIndicatorView.swift; sourceTree = ""; }; + F388D480F655DF7D8177661E /* SuggestionSessionReconciliationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconciliationTests.swift; sourceTree = ""; }; F3D991D3536ED989C89AF44D /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; F47CD9DF50E7192A70D1870E /* CotabbyDebugOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptions.swift; sourceTree = ""; }; F5067AE83AEE957322A47E60 /* LlamaSuggestionEngineCancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineCancellationTests.swift; sourceTree = ""; }; @@ -1377,10 +1429,16 @@ 2D8E398BA5F2ED675A25AAFD /* Suggestion */ = { isa = PBXGroup; children = ( + E6481CD84E28FE280775BA9F /* ActiveSuggestionSession.swift */, + C2692314CF62E130DD560B1A /* FocusedInputContext.swift */, + 8C6227F413EF448427888881 /* SuggestionClientError.swift */, + 114AC8D1D904F49DEFDAC0C6 /* SuggestionConfiguration.swift */, 3DB1A3F7D6C0FF28717E6D4C /* SuggestionEngineModels.swift */, - 977CE0DE2F43D3F964C0EC73 /* SuggestionModels.swift */, 17FD5822D3A75266B20A50CE /* SuggestionPauseModels.swift */, + 300472C842A2FA08A098BC9E /* SuggestionPresentationModels.swift */, 50119F67E7BB9FBC8EA94BF2 /* SuggestionQualityMetricsStore.swift */, + D2574E89992AA55C237DE995 /* SuggestionRequest.swift */, + 650CC43EEE4F50A91CAF83B2 /* SuggestionResult.swift */, 5652FFF8E718A17D98F87CD0 /* SuggestionSubsystemContracts.swift */, ); path = Suggestion; @@ -1396,6 +1454,11 @@ 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */, A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */, FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */, + 85BF56EDA9A873F7D75D7D81 /* EngineAndModelPaneView+Actions.swift */, + 34994266A9A9C8C525E4DEC9 /* EngineAndModelPaneView+AppleIntelligence.swift */, + CA9C51ED6D92911B0365F3E4 /* EngineAndModelPaneView+Endpoint.swift */, + 7BE08CD86E77A27E6BB7089B /* EngineAndModelPaneView+OpenSource.swift */, + A414F29774C2D28E07D89667 /* EngineAndModelPaneView+Power.swift */, 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */, D1123AB515110BD0CBA39490 /* HomePaneView.swift */, DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, @@ -1455,6 +1518,7 @@ children = ( F35CD1B715FCBB12BEB6CE62 /* FieldEdgeIconIndicatorView.swift */, 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */, + 9720913FECB22A9A28C96333 /* SuggestionOverlayViews.swift */, ); path = Overlays; sourceTree = ""; @@ -2124,6 +2188,7 @@ 1F70588152A9E06C46971ACC /* CompletionSeamGuardTests.swift */, F2F098B80AE83F7973168641 /* ConfidenceSuppressionPolicyTests.swift */, EC5160FA603A7227AC0F3A80 /* DebouncePolicyTests.swift */, + 7DF1F8FDB53538819537DB55 /* FocusSnapshotExternalApplicationIdentityTests.swift */, 2BAF8A580B615949356560AA /* InsertedTextAdvanceTests.swift */, 45E30CEB74593771E5F62E5E /* InsertionSafetyGateTests.swift */, 6EA8ECB565875395AA51E328 /* InsertionStrategySelectorTests.swift */, @@ -2132,9 +2197,16 @@ 1480CC831D97E0C55267E0EC /* SpeculativeAcceptanceContextTests.swift */, FA6A718EDDF3473A762705BE /* StreamedGhostTextPolicyTests.swift */, 7907403362CEEFB8FA24FCCD /* SuggestionAvailabilityEvaluatorTests.swift */, + A561EFA8C48EC0C8E9753B9B /* SuggestionInsertionChunkTests.swift */, + 917648CBC418C0D485BBF88F /* SuggestionOverlayAcceptanceTests.swift */, + 1FBEEAB65651DCA24AFD7E38 /* SuggestionPhraseAcceptanceTests.swift */, 499FA07A48925E79F091D9E7 /* SuggestionRequestFactoryTests.swift */, - DA1F9C5D85651740CC51B47F /* SuggestionSessionReconcilerTests.swift */, + F388D480F655DF7D8177661E /* SuggestionSessionReconciliationTests.swift */, + 356C8E766609ED3CC1501F24 /* SuggestionSessionTypingTests.swift */, + 34784852DCFDF959F76C7F78 /* SuggestionSettingsModelDisabledAppsTests.swift */, + 45DC0822E12D5700AE705E76 /* SuggestionStaleAcceptanceEchoTests.swift */, 5A520C43E88BA7D3CCF0E961 /* SuggestionTextNormalizerTests.swift */, + E0523515C5DAAED9475BB8A3 /* SuggestionWordAcceptanceTests.swift */, 7CC541A0FCCE9172C24C1350 /* SyntheticReplacePlannerTests.swift */, 800ADA19752797F5704D9B72 /* TrailingDuplicationFilterTests.swift */, ); @@ -2607,6 +2679,7 @@ 831B8DF92AAC8D0B99C5A262 /* AcceptanceModePickerView.swift in Sources */, AF86357E50ADC91838446A9A /* AcknowledgementsView.swift in Sources */, 56D2FDE72FE21C35E8BDD06C /* ActivationIndicatorController.swift in Sources */, + 2A7F62CE6415B5C5A4FC98E4 /* ActiveSuggestionSession.swift in Sources */, 9EB8E3DC796A0C8BFDE8E683 /* AppDelegate.swift in Sources */, 9DC064E0D7CD8A0F811A557D /* AppSurfaceClassifier.swift in Sources */, 4E8268A5C44B9E5945EBE59D /* AppUpdateManager.swift in Sources */, @@ -2670,6 +2743,11 @@ BD4A3B059DBB46C791FF3FF7 /* EmojiUsageModels.swift in Sources */, DA00477BB00EB3E95F204CB2 /* EmojiUsageStore.swift in Sources */, 30ECD51B71D7BEF422DD8290 /* EmojiVariantResolver.swift in Sources */, + 206D655F360EE8590ACD4CC3 /* EngineAndModelPaneView+Actions.swift in Sources */, + 48CBD4789B020D1CEAB3F73E /* EngineAndModelPaneView+AppleIntelligence.swift in Sources */, + 4EAE973B139CF9192F49EE03 /* EngineAndModelPaneView+Endpoint.swift in Sources */, + 46A7E7D70C4681B2BCCC4F48 /* EngineAndModelPaneView+OpenSource.swift in Sources */, + 7F015A80AE53B985AD83D149 /* EngineAndModelPaneView+Power.swift in Sources */, B67EEF6865EB7C2205605608 /* EngineAndModelPaneView.swift in Sources */, D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */, 3EF0A298B5590571B1C37282 /* FieldStyleCache.swift in Sources */, @@ -2683,6 +2761,7 @@ AA2419299B395D420FA930F5 /* FocusSnapshotResolver.swift in Sources */, 89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */, D1CB2B2D33E5B6C76DDB2A9C /* FocusTrackingModel.swift in Sources */, + 5303882C64971F1D5B477091 /* FocusedInputContext.swift in Sources */, BB3166B270865C8C6EFBA1CF /* FoundationModelAvailabilityService.swift in Sources */, 6B9B1630E1F30EAE429B5B60 /* FoundationModelPromptRenderer.swift in Sources */, B9F400BCC20757DA5DB0B5F9 /* FoundationModelSuggestionEngine.swift in Sources */, @@ -2793,6 +2872,8 @@ BC0B718DE571AC3AC182DC7E /* StreamedGhostTextPolicy.swift in Sources */, 955D2ED21FAEB1731AE84497 /* SuggestionAnchorCache.swift in Sources */, B5E3468CA0157FBEA53E96BD /* SuggestionAvailabilityEvaluator.swift in Sources */, + 1495BA844DB58A0DE69A1E6F /* SuggestionClientError.swift in Sources */, + C3A927EB44C650FCF622C58E /* SuggestionConfiguration.swift in Sources */, EC4ED03BE4C7DD0E6319F310 /* SuggestionCoordinator+Acceptance.swift in Sources */, AC4A369EC73115E1F698934D /* SuggestionCoordinator+Input.swift in Sources */, CAFDC7A78647485F9C0AD167 /* SuggestionCoordinator+Lifecycle.swift in Sources */, @@ -2804,12 +2885,15 @@ 80624558CD11E3275F1B6250 /* SuggestionFadeInPolicy.swift in Sources */, CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */, F28FB178EC507C3D42A6F893 /* SuggestionInteractionState.swift in Sources */, - FC537C9D5B0D5A61E6A7C4AA /* SuggestionModels.swift in Sources */, EE2C9177CE615298595215A8 /* SuggestionOverlayPresenter.swift in Sources */, 12A6C1479CC03A148E93391F /* SuggestionOverlayStabilityGate.swift in Sources */, + 996786038E31C671B650DFE4 /* SuggestionOverlayViews.swift in Sources */, 484766367056A7103131FAEA /* SuggestionPauseModels.swift in Sources */, + 1ABF3AC6E9EAACB537F23A74 /* SuggestionPresentationModels.swift in Sources */, C3C347E9485B70C59BF5EFC8 /* SuggestionQualityMetricsStore.swift in Sources */, + D397D3FF36E9AF8AD8421275 /* SuggestionRequest.swift in Sources */, 7209B91E3550735271B52E0F /* SuggestionRequestFactory.swift in Sources */, + 8BE1423F4375CEE6481771D0 /* SuggestionResult.swift in Sources */, B2F78CEF3A83C191E72214D4 /* SuggestionSessionReconciler.swift in Sources */, FB31D4954D031D97EDBB4C73 /* SuggestionSettingsData.swift in Sources */, BCD54829D3DD33D8B034AA73 /* SuggestionSettingsModel.swift in Sources */, @@ -2863,6 +2947,7 @@ DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */, 0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */, 72672FBC57236EF484340659 /* ActivationIndicatorController.swift in Sources */, + B10CB60110995AB9F402A363 /* ActiveSuggestionSession.swift in Sources */, 0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */, 9A17FE331C20BB85B65457DD /* AppSurfaceClassifier.swift in Sources */, 7955ED6ECA506D4EDA204BB1 /* AppUpdateManager.swift in Sources */, @@ -2926,6 +3011,11 @@ E2E830C1D5FEFE5F621018FE /* EmojiUsageModels.swift in Sources */, BDC565D0F9830B3FFDA4252C /* EmojiUsageStore.swift in Sources */, 398D5648DD9DB75EDB5B9C08 /* EmojiVariantResolver.swift in Sources */, + B6D05E46AA2ADDD00CCC888B /* EngineAndModelPaneView+Actions.swift in Sources */, + 2B18563E8907DE21A6BDC865 /* EngineAndModelPaneView+AppleIntelligence.swift in Sources */, + 5E599A4BDF1B93A9711EF487 /* EngineAndModelPaneView+Endpoint.swift in Sources */, + 2AF6D229913AA599FFB7A064 /* EngineAndModelPaneView+OpenSource.swift in Sources */, + DF1AE8F318CE582ED675CD7F /* EngineAndModelPaneView+Power.swift in Sources */, 5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */, A3ABF1872257F40B14CBA3C2 /* FieldEdgeIconIndicatorView.swift in Sources */, B782EC08B7516791BDB21172 /* FieldStyleCache.swift in Sources */, @@ -2939,6 +3029,7 @@ CF4205B85D881B8176590D25 /* FocusSnapshotResolver.swift in Sources */, B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */, 66CF2F55FA3E565C70D35C7D /* FocusTrackingModel.swift in Sources */, + 70F4E4A0D9471C43699FD708 /* FocusedInputContext.swift in Sources */, D0D4C0E28F5CD99669A49414 /* FoundationModelAvailabilityService.swift in Sources */, 53F946FF09038C1C751C6E53 /* FoundationModelPromptRenderer.swift in Sources */, 6DD1E22151571E1A22FF22F4 /* FoundationModelSuggestionEngine.swift in Sources */, @@ -3049,6 +3140,8 @@ 06358FF43FF44F773CCC6881 /* StreamedGhostTextPolicy.swift in Sources */, DDD1B7DA589286FE6D01DACF /* SuggestionAnchorCache.swift in Sources */, 1F12149EA676E69B75BDBFDA /* SuggestionAvailabilityEvaluator.swift in Sources */, + B52EA9A632976BCED9693E1B /* SuggestionClientError.swift in Sources */, + 397F1903F7C4B29F4BEC3FF2 /* SuggestionConfiguration.swift in Sources */, A0657CE0488F69F0BD559CBC /* SuggestionCoordinator+Acceptance.swift in Sources */, D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */, B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */, @@ -3060,12 +3153,15 @@ DDC49DC77FDC6395581700C2 /* SuggestionFadeInPolicy.swift in Sources */, 2FC40D4BFDD05C2401D7A5E9 /* SuggestionInserter.swift in Sources */, A982E243A4D8BC1BF7504B3E /* SuggestionInteractionState.swift in Sources */, - B8C71E65399B88D5543644DE /* SuggestionModels.swift in Sources */, 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */, F3CAFFDA47FAF0B4FADFC699 /* SuggestionOverlayStabilityGate.swift in Sources */, + 1CCDD5419039F1151AF7A7B9 /* SuggestionOverlayViews.swift in Sources */, AFEB6885A317284467CD44ED /* SuggestionPauseModels.swift in Sources */, + 8BD55AF37623F9901A3A39DF /* SuggestionPresentationModels.swift in Sources */, 980E02175E2D83D4B60FE519 /* SuggestionQualityMetricsStore.swift in Sources */, + 6B8240F5A193C1385524063B /* SuggestionRequest.swift in Sources */, 64CC33F721B377F4F0547685 /* SuggestionRequestFactory.swift in Sources */, + 73E981A8A491BED1A920A323 /* SuggestionResult.swift in Sources */, 96338F5360D3E1435DCCF087 /* SuggestionSessionReconciler.swift in Sources */, E3CF6ADB3DFAE5AB2F23935B /* SuggestionSettingsData.swift in Sources */, 5E43AD2C53EA1813A6A138E3 /* SuggestionSettingsModel.swift in Sources */, @@ -3163,6 +3259,7 @@ CED430DD80E4769920E3C0E5 /* FocusModelsTests.swift in Sources */, 5CBE09685D85DFED89B79437 /* FocusPollBackoffTests.swift in Sources */, 90CAE5A87EA9EA2670BD99DA /* FocusSessionScopedCacheTests.swift in Sources */, + 5D56B30A42CC2669E204E117 /* FocusSnapshotExternalApplicationIdentityTests.swift in Sources */, 1C6B2EBC77B508A3FF43E994 /* FocusSnapshotResolverLiveTests.swift in Sources */, 88318BAA3E0754D09C048983 /* FocusSnapshotResolverSelectionTests.swift in Sources */, C6EE8C731928DB2A26945127 /* FocusTrackingModelTests.swift in Sources */, @@ -3240,17 +3337,24 @@ F76ECB9663E46AA806E2C7DE /* SuggestionEngineRouterTests.swift in Sources */, 346A21884E25B07C338B33C9 /* SuggestionFadeInPolicyTests.swift in Sources */, F752C13542D72C888936748D /* SuggestionFocusFreshnessTests.swift in Sources */, + 73E51046737DD72C1168927C /* SuggestionInsertionChunkTests.swift in Sources */, 7AEF32D2BCB710A3E018A637 /* SuggestionInteractionStateTests.swift in Sources */, + 0B57FA3ACA73DD2A1858BC01 /* SuggestionOverlayAcceptanceTests.swift in Sources */, 8D63900E61EAA983D2C726FC /* SuggestionOverlayStabilityGateTests.swift in Sources */, 4A2AC346A5A857AF551D1598 /* SuggestionPauseModelsTests.swift in Sources */, + 98663EEE0FEA986130B55A25 /* SuggestionPhraseAcceptanceTests.swift in Sources */, 3FA7429EE4BDE4CE0D8E7DA0 /* SuggestionQualityMetricsStoreTests.swift in Sources */, 311445DF836037B2785E1539 /* SuggestionRequestFactoryTests.swift in Sources */, - 08EF35590D0C64D0D3FBACFE /* SuggestionSessionReconcilerTests.swift in Sources */, + 78F4CBAD2B7580A29F0F07C5 /* SuggestionSessionReconciliationTests.swift in Sources */, + E39A5FA3F8CE7BF6168A073C /* SuggestionSessionTypingTests.swift in Sources */, + 6139DA1889A939120969FD7A /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */, 2D83CECB0FF076323226449D /* SuggestionSettingsModelTests.swift in Sources */, 19449988F10AD289E2682F04 /* SuggestionSettingsStoreTests.swift in Sources */, + 0224B3567B9DC45BD731A7DC /* SuggestionStaleAcceptanceEchoTests.swift in Sources */, DA92312A2DE3518DAF0C7819 /* SuggestionStateHelperTests.swift in Sources */, 420C41A28EB788D077719978 /* SuggestionTextColorCodecTests.swift in Sources */, 021084DE49BDB797C67EA100 /* SuggestionTextNormalizerTests.swift in Sources */, + 6573CD78E36EF0C297CF291A /* SuggestionWordAcceptanceTests.swift in Sources */, CF04C28598AF9A437990C8A4 /* SurfaceContextComposerTests.swift in Sources */, 15D7C2CFE930F62B88F07EA7 /* SymSpellCorrectorTests.swift in Sources */, 19284043DCEDC562269991EE /* SymSpellTests.swift in Sources */, diff --git a/Cotabby/Models/Suggestion/ActiveSuggestionSession.swift b/Cotabby/Models/Suggestion/ActiveSuggestionSession.swift new file mode 100644 index 00000000..6514bc2e --- /dev/null +++ b/Cotabby/Models/Suggestion/ActiveSuggestionSession.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Durable interaction state for type-through and partial acceptance after generation completes. + +/// Represents one active inline-completion session after the model has produced a suggestion. +/// The key architectural shift is that a suggestion is no longer "fire once and forget." +/// Instead, it becomes durable interaction state that can be partially consumed over time. +struct ActiveSuggestionSession: Equatable, Sendable { + /// The focused field state that produced the original suggestion. + /// We keep this as the anchor so later text changes can be interpreted as: + /// "user consumed part of the suggestion" vs "user diverged from it." + let baseContext: FocusedInputContext + let fullText: String + let consumedCharacterCount: Int + let latency: TimeInterval + /// `.continuation` for normal forward suggestions; `.correction(typoWord:)` when the session + /// represents a typo fix. The acceptance path branches on this so corrections always commit the + /// whole word and replace the typo rather than appending forward text. + let kind: SuggestionKind + + init( + baseContext: FocusedInputContext, + fullText: String, + consumedCharacterCount: Int = 0, + latency: TimeInterval, + kind: SuggestionKind = .continuation + ) { + self.baseContext = baseContext + self.fullText = fullText + self.consumedCharacterCount = min(max(consumedCharacterCount, 0), fullText.count) + self.latency = latency + self.kind = kind + } + + var acceptedText: String { + fullText.leadingCharacters(consumedCharacterCount) + } + + var remainingText: String { + fullText.droppingLeadingCharacters(consumedCharacterCount) + } + + var acceptedCount: Int { + consumedCharacterCount + } + + var remainingCount: Int { + remainingText.count + } + + /// A whitespace-only tail is effectively exhausted for inline UX. + /// Showing "ghost spaces" is visually confusing and not worth preserving. + var isExhausted: Bool { + remainingText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Returns a new session advanced by the accepted or typed character count. + /// The original value stays unchanged because this type models immutable interaction state. + func advancing(by consumedCharacters: Int) -> ActiveSuggestionSession { + ActiveSuggestionSession( + baseContext: baseContext, + fullText: fullText, + consumedCharacterCount: self.consumedCharacterCount + max(consumedCharacters, 0), + latency: latency, + kind: kind + ) + } + + /// Rebuilds the session from a fully observed live editor state during reconciliation. + /// This is useful when AX catches up after optimistic UI updates such as partial Tab accepts. + func withConsumedCharacters(_ consumedCharacters: Int) -> ActiveSuggestionSession { + ActiveSuggestionSession( + baseContext: baseContext, + fullText: fullText, + consumedCharacterCount: consumedCharacters, + latency: latency, + kind: kind + ) + } +} + +/// Records the chunk committed by the most recent full acceptance and the field text it was +/// appended after. The coordinator stamps this on a final-chunk accept and consumes it on the next +/// generation. If the model only re-proposes `text` while the live preceding text still equals +/// `precedingText`, the host has not published our insert yet (the Chromium AX-publish race), so the +/// suggestion is dropped instead of looping accept/regenerate/accept on the last word. +struct AcceptedSuggestionTail: Equatable, Sendable { + let text: String + let precedingText: String +} + +private extension String { + /// Swift `String` is a collection of extended grapheme clusters, not bytes. + /// These helpers slice by user-visible characters so emoji and composed characters stay intact. + /// That matters because autocomplete acceptance is a user-facing action, not a byte-level one. + func leadingCharacters(_ count: Int) -> String { + String(prefix(max(count, 0))) + } + + func droppingLeadingCharacters(_ count: Int) -> String { + String(dropFirst(max(count, 0))) + } +} diff --git a/Cotabby/Models/Suggestion/FocusedInputContext.swift b/Cotabby/Models/Suggestion/FocusedInputContext.swift new file mode 100644 index 00000000..f8409990 --- /dev/null +++ b/Cotabby/Models/Suggestion/FocusedInputContext.swift @@ -0,0 +1,118 @@ +import CoreGraphics +import Foundation + +/// Stable, bounded focus state captured before debounce and carried through generation. +/// Keeping this value independent of live AX elements is a central stale-result safety boundary. + +/// This is the stable context used across debounce and generation boundaries. +/// It extends the AX snapshot with a monotonically increasing generation number. +struct FocusedInputContext: Equatable, Sendable { + let applicationName: String + let bundleIdentifier: String + let processIdentifier: Int32 + let elementIdentifier: String + let role: String + let subrole: String? + let caretRect: CGRect + let inputFrameRect: CGRect? + let caretQuality: CaretGeometryQuality + /// Human-readable label of the geometry source that produced `caretRect` (e.g. "derived + /// primary"), carried through so presentation-time repair logs can attribute a misplaced + /// overlay to the exact resolver branch. + let caretSource: String + /// Average character width in points observed from AX child frame measurements. + /// Used by caret prediction after tab insertion to match the target app's actual font. + let observedCharWidth: CGFloat? + /// Content edges measured from the host's child text-run frames, carried through so the caret + /// layout estimator can anchor to the field's real padding instead of guessed insets. + let observedContentEdges: ObservedContentEdges? + let precedingText: String + let trailingText: String + let selection: NSRange + let isSecure: Bool + /// Whether the field's text is rendered by a web engine (see `WebContentFieldDetector`), + /// carried through so the presentation-time caret repair can scope estimator authority to + /// hosts whose AX caret geometry actually needs repairing. + let isWebContentField: Bool + /// The host field's own text font/color, carried through so the overlay can match it. + let resolvedFieldStyle: ResolvedFieldStyle? + /// Surface metadata captured once per field session, carried through so the request factory + /// can condition the prompt on what the user is writing in (see `SurfaceContextComposer`). + let windowTitle: String? + let fieldPlaceholder: String? + let focusedURLString: String? + let isIntegratedTerminal: Bool + /// Carries the immutable focus-observation identity across debounce/generation boundaries. + /// Without this, later visual-context lookups could fall back to `elementIdentifier` alone and + /// reintroduce the CFHash collision class this sequence is meant to avoid. + let focusChangeSequence: UInt64 + let generation: UInt64 + + init(snapshot: FocusedInputSnapshot, generation: UInt64) { + applicationName = snapshot.applicationName + bundleIdentifier = snapshot.bundleIdentifier + processIdentifier = snapshot.processIdentifier + elementIdentifier = snapshot.elementIdentifier + role = snapshot.role + subrole = snapshot.subrole + caretRect = snapshot.caretRect + inputFrameRect = snapshot.inputFrameRect + caretQuality = snapshot.caretQuality + caretSource = snapshot.caretSource + observedCharWidth = snapshot.observedCharWidth + observedContentEdges = snapshot.observedContentEdges + precedingText = snapshot.precedingText + trailingText = snapshot.trailingText + selection = snapshot.selection + isSecure = snapshot.isSecure + isWebContentField = snapshot.isWebContentField + resolvedFieldStyle = snapshot.resolvedFieldStyle + windowTitle = snapshot.windowTitle + fieldPlaceholder = snapshot.fieldPlaceholder + focusedURLString = snapshot.focusedURLString + isIntegratedTerminal = snapshot.isIntegratedTerminal + focusChangeSequence = snapshot.focusChangeSequence + self.generation = generation + } + + var identity: FocusedInputIdentity { + FocusedInputIdentity( + elementIdentifier: elementIdentifier, + focusChangeSequence: focusChangeSequence + ) + } + + /// True when the caret is at the end of its line (only whitespace, if anything, before the next + /// line break). Derived from `trailingText` via `CaretLinePosition`; used to decide when a + /// mid-line completion strategy like fill-in-middle applies versus a plain forward continuation. + var isCaretAtEndOfLine: Bool { + CaretLinePosition.isAtEndOfLine(trailingText: trailingText) + } + + /// Stable per-process key for the focused field, intentionally NOT including the input frame + /// rect. The polling signature in `FocusTracker` bumps `focusChangeSequence` whenever the + /// field's frame changes (e.g., a chat composer growing taller as the user types wraps onto a + /// second line). For consumers that should treat self-resizing as "same field" — chief among + /// them ghost-font stabilization — this key gives them a session identity that survives field + /// growth. `hashValue` is randomized per process, which is fine: the key is only ever compared + /// within one process's lifetime. + var focusedInputIdentityKey: UInt64 { + var hasher = Hasher() + hasher.combine(bundleIdentifier) + hasher.combine(processIdentifier) + hasher.combine(elementIdentifier) + return UInt64(bitPattern: Int64(hasher.finalize())) + } + + /// Content-only fingerprint — mirrors `FocusedInputSnapshot.contentSignature`. + /// See that type's doc comment for why `elementIdentifier` is excluded. + var contentSignature: String { + [ + String(selection.location), + String(selection.length), + precedingText, + trailingText, + isSecure ? "secure" : "plain" + ].joined(separator: "::") + } +} diff --git a/Cotabby/Models/Suggestion/SuggestionClientError.swift b/Cotabby/Models/Suggestion/SuggestionClientError.swift new file mode 100644 index 00000000..b36d7b57 --- /dev/null +++ b/Cotabby/Models/Suggestion/SuggestionClientError.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Errors shared by suggestion backends and coordinator-facing normalization. + +/// Errors specific to suggestion generation and normalization. +enum SuggestionClientError: LocalizedError { + case unavailable(String) + case unsupportedLanguageOrLocale(String) + case generationFailed(String) + case cancelled + + var errorDescription: String? { + switch self { + case let .unavailable(message), + let .unsupportedLanguageOrLocale(message), + let .generationFailed(message): + return message + case .cancelled: + return "Generation was cancelled." + } + } +} diff --git a/Cotabby/Models/Suggestion/SuggestionConfiguration.swift b/Cotabby/Models/Suggestion/SuggestionConfiguration.swift new file mode 100644 index 00000000..cc2863fe --- /dev/null +++ b/Cotabby/Models/Suggestion/SuggestionConfiguration.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Generation limits and user-selected behavior shared by request construction and every backend. +/// These values are immutable snapshots so asynchronous work cannot observe settings changing mid-request. + +/// Closed integer range a suggestion's word count should fall within. +/// Used by both the curated `SuggestionWordCountPreset` and the user-defined custom range so the +/// prompt builder and token-budget math have a single shape to consume. +struct SuggestionWordRange: Equatable, Hashable, Sendable { + let lowWords: Int + let highWords: Int + + /// Hard bounds for any range that ends up driving generation. The floor of 1 keeps the model + /// from being asked for zero words; the ceiling caps how much we'll plan for so a typo'd "9999" + /// can't blow out the token budget. + static let minimumWord: Int = 1 + static let maximumWord: Int = 50 + + /// Clamps both ends to `[minimumWord, maximumWord]` and ensures low <= high (snapping high up to + /// low when the user temporarily inverts them via two separate steppers). + static func clamped(low: Int, high: Int) -> SuggestionWordRange { + let clampedLow = min(max(low, minimumWord), maximumWord) + let clampedHigh = min(max(high, clampedLow), maximumWord) + return SuggestionWordRange(lowWords: clampedLow, highWords: clampedHigh) + } + + var displayLabel: String { "\(lowWords)-\(highWords) words" } + var compactLabel: String { "\(lowWords)-\(highWords) w" } + var promptInstruction: String { "Return only the next \(lowWords) to \(highWords) words." } +} + +/// User-facing presets that bound how long one inline suggestion may be. +/// Treating this as an enum keeps the UI and prompt policy in one source of truth. +enum SuggestionWordCountPreset: String, CaseIterable, Equatable, Hashable, Sendable, Identifiable { + case twoToFour = "2-4" + case fourToSeven = "4-7" + case sevenToTwelve = "7-12" + case twelveToTwenty = "12-20" + + var id: String { rawValue } + + var displayLabel: String { + "\(rawValue) words" + } + + /// Compact labels are useful in tight menu-bar controls where the full descriptive copy + /// would dominate the layout. + var compactLabel: String { + "\(rawValue) w" + } + + var promptInstruction: String { + switch self { + case .twoToFour: + return "Return only the next 2 to 4 words." + case .fourToSeven: + return "Return only the next 4 to 7 words." + case .sevenToTwelve: + return "Return only the next 7 to 12 words." + case .twelveToTwenty: + return "Return only the next 12 to 20 words." + } + } + + /// The shared `SuggestionWordRange` shape so the prompt builder and token-budget math can + /// consume presets and custom ranges identically. + var range: SuggestionWordRange { + switch self { + case .twoToFour: + return SuggestionWordRange(lowWords: 2, highWords: 4) + case .fourToSeven: + return SuggestionWordRange(lowWords: 4, highWords: 7) + case .sevenToTwelve: + return SuggestionWordRange(lowWords: 7, highWords: 12) + case .twelveToTwenty: + return SuggestionWordRange(lowWords: 12, highWords: 20) + } + } + + /// Token budget is the sole governor of completion length on the local model (the in-prompt + /// word-range cue was removed), so it must track the upper word bound closely. Now derived from + /// the language-aware factor in `LanguageCatalog`; this convenience returns the English-default + /// number for callers/tests that don't have a language context handy. History: a ~1.5x sizing + /// (6/11/18/30) still overran because real prose uses fewer tokens per word than that assumed, + /// and an earlier 50% bump overran further, e.g. ~12 words on the shortest preset (#271). When + /// unsure, bias shorter; a clipped suggestion is cheaper than one that blows past the setting. + var suggestedPredictionTokenBudget: Int { + SuggestionWordRange.predictionTokenBudget( + highWords: range.highWords, + tokensPerWord: LanguageCatalog.fallbackTokensPerWord + ) + } +} + +extension SuggestionWordRange { + /// Maps a word range upper bound and a per-word factor onto a token budget, rounded up so the + /// cap lands at or just past the upper word bound rather than systematically under it. + static func predictionTokenBudget(highWords: Int, tokensPerWord: Double) -> Int { + Int(ceil(Double(highWords) * tokensPerWord)) + } +} + +/// Persisted indicator mode values. Only `hidden` and `fieldEdgeIcon` are active; +/// the enum exists so UserDefaults round-trips through a stable raw value. +enum ActivationIndicatorMode: String, Equatable, Hashable, Sendable { + case hidden + case fieldEdgeIcon +} + +/// Runtime knobs for the inline-completion pipeline. +/// Keeping these in one struct makes it easier to reason about product defaults versus +/// experimental tuning without scattering magic numbers through the coordinator. +struct SuggestionConfiguration: Equatable, Sendable { + let maxPredictionTokens: Int + let debounceMilliseconds: Int + let temperature: Double + let topK: Int + let topP: Double + let minP: Double + let repetitionPenalty: Double + /// Optional fixed seed for deterministic llama sampling. + /// Production keeps this nil so suggestions can vary naturally; tests and microbenches can set + /// it to prove cached and uncached decoding produce the same output for the same sampler state. + let randomSeed: UInt32? + let maxPrefixWords: Int + let maxPrefixCharacters: Int + /// Foundation Models has a noticeably larger shared context than the local llama path, so the + /// FM-selected request gets a separate (larger) prefix budget. Setting this above the llama + /// caps avoids crowding instructions while keeping the local-continuation focus. + let maxPrefixWordsFoundationModel: Int + let maxPrefixCharactersFoundationModel: Int + let maxSuffixCharacters: Int + /// Estimated-token ceiling for the llama prompt (preface + prefix). Derived from the runtime's + /// per-sequence context window minus the output budget and a safety margin, so the renderer + /// truncates against what the model can actually hold instead of a flat character guess that + /// misjudges code, CJK, and punctuation-heavy text. + let llamaPromptTokenBudget: Int + /// Shipped first-launch default for the user's saved profile. + /// `SuggestionSettingsModel` persists the user's real preference; configuration only provides + /// the app's starting value for a fresh install. + let defaultUserName: String? + let defaultWordCountPreset: SuggestionWordCountPreset + let focusPollIntervalMilliseconds: Int + + /// Output ceiling reserved out of the llama context window when sizing the prompt budget: + /// the largest realistic per-request token budget (multi-line doubles the 26-token default). + static let llamaPromptOutputCeilingTokens = 50 + /// Margin for BOS plus token-estimator error; the estimator skews conservative, so real + /// prompts land under the derived budget. + static let llamaPromptSafetyMarginTokens = 64 + /// The per-sequence KV capacity minus the output ceiling and safety margin. Computed from + /// `LlamaRuntimeConfiguration.default` so the two constants cannot drift apart silently. + static var derivedLlamaPromptTokenBudget: Int { + Int(LlamaRuntimeConfiguration.default.contextWindowTokens) + - llamaPromptOutputCeilingTokens + - llamaPromptSafetyMarginTokens + } + + /// The configuration shipped by the app today. + /// These are product defaults, not temporary debug overrides. + static let standard = SuggestionConfiguration( + // Floor for the per-request token budget (see SuggestionRequestFactory.activeMaxPredictionTokens). + // Held at the smallest word-count preset (2-4 words) so that preset's budget governs instead + // of being silently raised; keeps ghost text short, fast, and easy to accept. + maxPredictionTokens: 5, + // Aggressive debounce: 20ms keeps time-to-first-suggestion low while still collapsing + // bursts (superseded generations are cancelled; the host-publish poll absorbs AX lag). + debounceMilliseconds: 20, + // Low temperature keeps inline completions stable and less likely to drift. + temperature: 0.1, + topK: 20, + topP: 0.7, + minP: 0.08, + repetitionPenalty: 1.05, + randomSeed: nil, + maxPrefixWords: 150, + // The llama prefix window matches the Foundation Models one: the extra preceding sentences + // carry the topic and voice that multi-paragraph email/docs continuations need, and the + // token budget below keeps the total prompt bounded by what the model can hold. Latency + // honesty: where KV prefix reuse works (dense models), the larger window is prefilled once + // per field; the hybrid/SWA catalog models reject partial trims and re-prefill per request, + // so there the wider window costs prefill only when the field actually holds more than the + // old 1000-char cap, i.e. long-document sessions, which is exactly where it buys quality. + maxPrefixCharacters: 2500, + // Apple's on-device model has a 4096-token shared context. Even with instructions plus + // visual/clipboard context, there is room to send ~3x the llama window before crowding + // the prompt, and the extra surrounding sentences materially help mid-thought completions. + maxPrefixWordsFoundationModel: 150, + maxPrefixCharactersFoundationModel: 2500, + maxSuffixCharacters: 192, + // Derived from the runtime constant so a context-window change can never silently + // desynchronize the prompt budget from the KV capacity the model actually has. + llamaPromptTokenBudget: SuggestionConfiguration.derivedLlamaPromptTokenBudget, + // Seed the profile settings with lightweight defaults on first launch. + defaultUserName: "Jacob", + defaultWordCountPreset: .twelveToTwenty, + focusPollIntervalMilliseconds: 50 + ) +} diff --git a/Cotabby/Models/Suggestion/SuggestionModels.swift b/Cotabby/Models/Suggestion/SuggestionModels.swift deleted file mode 100644 index dfc44b15..00000000 --- a/Cotabby/Models/Suggestion/SuggestionModels.swift +++ /dev/null @@ -1,766 +0,0 @@ -import CoreGraphics -import Foundation - -/// File overview: -/// Defines the pure value types that describe Cotabby's autocomplete domain: -/// configuration, generation requests, normalized model output, active suggestion sessions, -/// and overlay visibility. -/// -/// This file is intentionally free of AppKit, AX, and runtime side effects so maintainers can -/// understand the core state machine without reading OS integration code first. - -/// Closed integer range a suggestion's word count should fall within. -/// Used by both the curated `SuggestionWordCountPreset` and the user-defined custom range so the -/// prompt builder and token-budget math have a single shape to consume. -struct SuggestionWordRange: Equatable, Hashable, Sendable { - let lowWords: Int - let highWords: Int - - /// Hard bounds for any range that ends up driving generation. The floor of 1 keeps the model - /// from being asked for zero words; the ceiling caps how much we'll plan for so a typo'd "9999" - /// can't blow out the token budget. - static let minimumWord: Int = 1 - static let maximumWord: Int = 50 - - /// Clamps both ends to `[minimumWord, maximumWord]` and ensures low <= high (snapping high up to - /// low when the user temporarily inverts them via two separate steppers). - static func clamped(low: Int, high: Int) -> SuggestionWordRange { - let clampedLow = min(max(low, minimumWord), maximumWord) - let clampedHigh = min(max(high, clampedLow), maximumWord) - return SuggestionWordRange(lowWords: clampedLow, highWords: clampedHigh) - } - - var displayLabel: String { "\(lowWords)-\(highWords) words" } - var compactLabel: String { "\(lowWords)-\(highWords) w" } - var promptInstruction: String { "Return only the next \(lowWords) to \(highWords) words." } -} - -/// User-facing presets that bound how long one inline suggestion may be. -/// Treating this as an enum keeps the UI and prompt policy in one source of truth. -enum SuggestionWordCountPreset: String, CaseIterable, Equatable, Hashable, Sendable, Identifiable { - case twoToFour = "2-4" - case fourToSeven = "4-7" - case sevenToTwelve = "7-12" - case twelveToTwenty = "12-20" - - var id: String { rawValue } - - var displayLabel: String { - "\(rawValue) words" - } - - /// Compact labels are useful in tight menu-bar controls where the full descriptive copy - /// would dominate the layout. - var compactLabel: String { - "\(rawValue) w" - } - - var promptInstruction: String { - switch self { - case .twoToFour: - return "Return only the next 2 to 4 words." - case .fourToSeven: - return "Return only the next 4 to 7 words." - case .sevenToTwelve: - return "Return only the next 7 to 12 words." - case .twelveToTwenty: - return "Return only the next 12 to 20 words." - } - } - - /// The shared `SuggestionWordRange` shape so the prompt builder and token-budget math can - /// consume presets and custom ranges identically. - var range: SuggestionWordRange { - switch self { - case .twoToFour: - return SuggestionWordRange(lowWords: 2, highWords: 4) - case .fourToSeven: - return SuggestionWordRange(lowWords: 4, highWords: 7) - case .sevenToTwelve: - return SuggestionWordRange(lowWords: 7, highWords: 12) - case .twelveToTwenty: - return SuggestionWordRange(lowWords: 12, highWords: 20) - } - } - - /// Token budget is the sole governor of completion length on the local model (the in-prompt - /// word-range cue was removed), so it must track the upper word bound closely. Now derived from - /// the language-aware factor in `LanguageCatalog`; this convenience returns the English-default - /// number for callers/tests that don't have a language context handy. History: a ~1.5x sizing - /// (6/11/18/30) still overran because real prose uses fewer tokens per word than that assumed, - /// and an earlier 50% bump overran further, e.g. ~12 words on the shortest preset (#271). When - /// unsure, bias shorter; a clipped suggestion is cheaper than one that blows past the setting. - var suggestedPredictionTokenBudget: Int { - SuggestionWordRange.predictionTokenBudget( - highWords: range.highWords, - tokensPerWord: LanguageCatalog.fallbackTokensPerWord - ) - } -} - -extension SuggestionWordRange { - /// Maps a word range upper bound and a per-word factor onto a token budget, rounded up so the - /// cap lands at or just past the upper word bound rather than systematically under it. - static func predictionTokenBudget(highWords: Int, tokensPerWord: Double) -> Int { - Int(ceil(Double(highWords) * tokensPerWord)) - } -} - -/// Persisted indicator mode values. Only `hidden` and `fieldEdgeIcon` are active; -/// the enum exists so UserDefaults round-trips through a stable raw value. -enum ActivationIndicatorMode: String, Equatable, Hashable, Sendable { - case hidden - case fieldEdgeIcon -} - -/// Runtime knobs for the inline-completion pipeline. -/// Keeping these in one struct makes it easier to reason about product defaults versus -/// experimental tuning without scattering magic numbers through the coordinator. -struct SuggestionConfiguration: Equatable, Sendable { - let maxPredictionTokens: Int - let debounceMilliseconds: Int - let temperature: Double - let topK: Int - let topP: Double - let minP: Double - let repetitionPenalty: Double - /// Optional fixed seed for deterministic llama sampling. - /// Production keeps this nil so suggestions can vary naturally; tests and microbenches can set - /// it to prove cached and uncached decoding produce the same output for the same sampler state. - let randomSeed: UInt32? - let maxPrefixWords: Int - let maxPrefixCharacters: Int - /// Foundation Models has a noticeably larger shared context than the local llama path, so the - /// FM-selected request gets a separate (larger) prefix budget. Setting this above the llama - /// caps avoids crowding instructions while keeping the local-continuation focus. - let maxPrefixWordsFoundationModel: Int - let maxPrefixCharactersFoundationModel: Int - let maxSuffixCharacters: Int - /// Estimated-token ceiling for the llama prompt (preface + prefix). Derived from the runtime's - /// per-sequence context window minus the output budget and a safety margin, so the renderer - /// truncates against what the model can actually hold instead of a flat character guess that - /// misjudges code, CJK, and punctuation-heavy text. - let llamaPromptTokenBudget: Int - /// Shipped first-launch default for the user's saved profile. - /// `SuggestionSettingsModel` persists the user's real preference; configuration only provides - /// the app's starting value for a fresh install. - let defaultUserName: String? - let defaultWordCountPreset: SuggestionWordCountPreset - let focusPollIntervalMilliseconds: Int - - /// Output ceiling reserved out of the llama context window when sizing the prompt budget: - /// the largest realistic per-request token budget (multi-line doubles the 26-token default). - static let llamaPromptOutputCeilingTokens = 50 - /// Margin for BOS plus token-estimator error; the estimator skews conservative, so real - /// prompts land under the derived budget. - static let llamaPromptSafetyMarginTokens = 64 - /// The per-sequence KV capacity minus the output ceiling and safety margin. Computed from - /// `LlamaRuntimeConfiguration.default` so the two constants cannot drift apart silently. - static var derivedLlamaPromptTokenBudget: Int { - Int(LlamaRuntimeConfiguration.default.contextWindowTokens) - - llamaPromptOutputCeilingTokens - - llamaPromptSafetyMarginTokens - } - - /// The configuration shipped by the app today. - /// These are product defaults, not temporary debug overrides. - static let standard = SuggestionConfiguration( - // Floor for the per-request token budget (see SuggestionRequestFactory.activeMaxPredictionTokens). - // Held at the smallest word-count preset (2-4 words) so that preset's budget governs instead - // of being silently raised; keeps ghost text short, fast, and easy to accept. - maxPredictionTokens: 5, - // Aggressive debounce: 20ms keeps time-to-first-suggestion low while still collapsing - // bursts (superseded generations are cancelled; the host-publish poll absorbs AX lag). - debounceMilliseconds: 20, - // Low temperature keeps inline completions stable and less likely to drift. - temperature: 0.1, - topK: 20, - topP: 0.7, - minP: 0.08, - repetitionPenalty: 1.05, - randomSeed: nil, - maxPrefixWords: 150, - // The llama prefix window matches the Foundation Models one: the extra preceding sentences - // carry the topic and voice that multi-paragraph email/docs continuations need, and the - // token budget below keeps the total prompt bounded by what the model can hold. Latency - // honesty: where KV prefix reuse works (dense models), the larger window is prefilled once - // per field; the hybrid/SWA catalog models reject partial trims and re-prefill per request, - // so there the wider window costs prefill only when the field actually holds more than the - // old 1000-char cap, i.e. long-document sessions, which is exactly where it buys quality. - maxPrefixCharacters: 2500, - // Apple's on-device model has a 4096-token shared context. Even with instructions plus - // visual/clipboard context, there is room to send ~3x the llama window before crowding - // the prompt, and the extra surrounding sentences materially help mid-thought completions. - maxPrefixWordsFoundationModel: 150, - maxPrefixCharactersFoundationModel: 2500, - maxSuffixCharacters: 192, - // Derived from the runtime constant so a context-window change can never silently - // desynchronize the prompt budget from the KV capacity the model actually has. - llamaPromptTokenBudget: SuggestionConfiguration.derivedLlamaPromptTokenBudget, - // Seed the profile settings with lightweight defaults on first launch. - defaultUserName: "Jacob", - defaultWordCountPreset: .twelveToTwenty, - focusPollIntervalMilliseconds: 50 - ) -} - -/// This is the stable context used across debounce and generation boundaries. -/// It extends the AX snapshot with a monotonically increasing generation number. -struct FocusedInputContext: Equatable, Sendable { - let applicationName: String - let bundleIdentifier: String - let processIdentifier: Int32 - let elementIdentifier: String - let role: String - let subrole: String? - let caretRect: CGRect - let inputFrameRect: CGRect? - let caretQuality: CaretGeometryQuality - /// Human-readable label of the geometry source that produced `caretRect` (e.g. "derived - /// primary"), carried through so presentation-time repair logs can attribute a misplaced - /// overlay to the exact resolver branch. - let caretSource: String - /// Average character width in points observed from AX child frame measurements. - /// Used by caret prediction after tab insertion to match the target app's actual font. - let observedCharWidth: CGFloat? - /// Content edges measured from the host's child text-run frames, carried through so the caret - /// layout estimator can anchor to the field's real padding instead of guessed insets. - let observedContentEdges: ObservedContentEdges? - let precedingText: String - let trailingText: String - let selection: NSRange - let isSecure: Bool - /// Whether the field's text is rendered by a web engine (see `WebContentFieldDetector`), - /// carried through so the presentation-time caret repair can scope estimator authority to - /// hosts whose AX caret geometry actually needs repairing. - let isWebContentField: Bool - /// The host field's own text font/color, carried through so the overlay can match it. - let resolvedFieldStyle: ResolvedFieldStyle? - /// Surface metadata captured once per field session, carried through so the request factory - /// can condition the prompt on what the user is writing in (see `SurfaceContextComposer`). - let windowTitle: String? - let fieldPlaceholder: String? - let focusedURLString: String? - let isIntegratedTerminal: Bool - /// Carries the immutable focus-observation identity across debounce/generation boundaries. - /// Without this, later visual-context lookups could fall back to `elementIdentifier` alone and - /// reintroduce the CFHash collision class this sequence is meant to avoid. - let focusChangeSequence: UInt64 - let generation: UInt64 - - init(snapshot: FocusedInputSnapshot, generation: UInt64) { - applicationName = snapshot.applicationName - bundleIdentifier = snapshot.bundleIdentifier - processIdentifier = snapshot.processIdentifier - elementIdentifier = snapshot.elementIdentifier - role = snapshot.role - subrole = snapshot.subrole - caretRect = snapshot.caretRect - inputFrameRect = snapshot.inputFrameRect - caretQuality = snapshot.caretQuality - caretSource = snapshot.caretSource - observedCharWidth = snapshot.observedCharWidth - observedContentEdges = snapshot.observedContentEdges - precedingText = snapshot.precedingText - trailingText = snapshot.trailingText - selection = snapshot.selection - isSecure = snapshot.isSecure - isWebContentField = snapshot.isWebContentField - resolvedFieldStyle = snapshot.resolvedFieldStyle - windowTitle = snapshot.windowTitle - fieldPlaceholder = snapshot.fieldPlaceholder - focusedURLString = snapshot.focusedURLString - isIntegratedTerminal = snapshot.isIntegratedTerminal - focusChangeSequence = snapshot.focusChangeSequence - self.generation = generation - } - - var identity: FocusedInputIdentity { - FocusedInputIdentity( - elementIdentifier: elementIdentifier, - focusChangeSequence: focusChangeSequence - ) - } - - /// True when the caret is at the end of its line (only whitespace, if anything, before the next - /// line break). Derived from `trailingText` via `CaretLinePosition`; used to decide when a - /// mid-line completion strategy like fill-in-middle applies versus a plain forward continuation. - var isCaretAtEndOfLine: Bool { - CaretLinePosition.isAtEndOfLine(trailingText: trailingText) - } - - /// Stable per-process key for the focused field, intentionally NOT including the input frame - /// rect. The polling signature in `FocusTracker` bumps `focusChangeSequence` whenever the - /// field's frame changes (e.g., a chat composer growing taller as the user types wraps onto a - /// second line). For consumers that should treat self-resizing as "same field" — chief among - /// them ghost-font stabilization — this key gives them a session identity that survives field - /// growth. `hashValue` is randomized per process, which is fine: the key is only ever compared - /// within one process's lifetime. - var focusedInputIdentityKey: UInt64 { - var hasher = Hasher() - hasher.combine(bundleIdentifier) - hasher.combine(processIdentifier) - hasher.combine(elementIdentifier) - return UInt64(bitPattern: Int64(hasher.finalize())) - } - - /// Content-only fingerprint — mirrors `FocusedInputSnapshot.contentSignature`. - /// See that type's doc comment for why `elementIdentifier` is excluded. - var contentSignature: String { - [ - String(selection.location), - String(selection.length), - precedingText, - trailingText, - isSecure ? "secure" : "plain" - ].joined(separator: "::") - } -} - -/// Distinguishes a normal forward continuation from a typo-correction reply. -/// -/// `.correction(typoWord:)` carries the exact word the correction was offered for, so the acceptance -/// path can confirm the live field still ends with that word before deleting it (guarding against a -/// keystroke that slipped in between), then replace it with the corrected word. This lets a -/// correction reuse the same session/overlay machinery as a plain continuation while committing as a -/// whole-word replacement. -enum SuggestionKind: Equatable, Sendable { - case continuation - case correction(typoWord: String) - - var isCorrection: Bool { - if case .correction = self { - return true - } - return false - } -} - -/// One generation request sent from the coordinator into the suggestion engine. -struct SuggestionRequest: Equatable, Sendable { - let context: FocusedInputContext - /// The truncated text immediately before the caret. - /// This stays backend-agnostic and gives every engine access to the same local writing context - /// even if they render prompts differently. - let prefixText: String - /// The canonical prompt payload for prompt-oriented backends such as the local llama runtime. - /// Engines that prefer a separate instructions channel can derive their own request text from - /// `prefixText` and the other shared fields instead of consuming this string directly. - let prompt: String - let generation: UInt64 - let maxPredictionTokens: Int - let temperature: Double - let topK: Int - let topP: Double - let minP: Double - let repetitionPenalty: Double - /// Optional deterministic sampler seed. `nil` preserves production randomness; tests and - /// microbenches can set this so cached and uncached runtime paths are directly comparable. - let randomSeed: UInt32? - let maxSuffixCharacters: Int - /// Explicit length guidance stays separate from user style preferences so prompt builders can - /// order and phrase them differently per backend. - let completionLengthInstruction: String - /// Optional user-provided profile context. We keep this separate from base product behavior so - /// future settings/personalization work can evolve independently from prompt safety rules. - let userName: String? - /// User-authored style rules rendered as additional prompt directives, subordinate to the base - /// autocomplete/safety rules. Empty when the user has none. - let customRules: [String] - /// User-authored free-form context (glossary, jargon, style notes) injected verbatim into the - /// prompt. Already trimmed and length-capped upstream so renderers can treat it as a ready-to-use - /// string. `nil` when the user has not set it, distinguishing it from an empty-but-set value so - /// renderers can skip the heading entirely. - let extendedContext: String? - /// Pre-rendered language hint built from the user's declared languages (e.g. "The user usually - /// writes in German and English…"). `nil` when none are declared. Deliberately a hint, not an - /// override: it tells the model to match the surrounding text and only fall back to the declared - /// languages when that text is ambiguous, which protects code-switching. - let languageInstruction: String? - /// Ephemeral clipboard context captured only when the user has enabled clipboard prompting. - let clipboardContext: String? - /// Ephemeral screen context summary injected only when available for the active text field. - let visualContextSummary: String? - /// The composed writing-surface description (app class, window title, domain, placeholder), - /// nil when the user disabled surface context or the surface class suppresses it. The llama - /// 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? - /// When enabled, the normalizer keeps multiple lines instead of truncating to the first line. - let isMultiLineEnabled: Bool - /// Correlation ID stamped onto every log line touching this request — coordinator state - /// transitions, router selection, engine generation, LLM I/O capture, insertion. Generated by - /// `RequestID.generate()` in `SuggestionRequestFactory`. Defaulted in the init so test fixtures - /// that build requests directly do not need to change. - let requestID: String - - init( - context: FocusedInputContext, - prefixText: String, - prompt: String, - generation: UInt64, - maxPredictionTokens: Int, - temperature: Double, - topK: Int, - topP: Double, - minP: Double, - repetitionPenalty: Double, - randomSeed: UInt32?, - maxSuffixCharacters: Int, - completionLengthInstruction: String, - userName: String?, - customRules: [String], - extendedContext: String? = nil, - languageInstruction: String?, - clipboardContext: String?, - visualContextSummary: String?, - surfaceContext: SurfaceContext? = nil, - isMultiLineEnabled: Bool, - requestID: String = "req_unknown" - ) { - self.context = context - self.prefixText = prefixText - self.prompt = prompt - self.generation = generation - self.maxPredictionTokens = maxPredictionTokens - self.temperature = temperature - self.topK = topK - self.topP = topP - self.minP = minP - self.repetitionPenalty = repetitionPenalty - self.randomSeed = randomSeed - self.maxSuffixCharacters = maxSuffixCharacters - self.completionLengthInstruction = completionLengthInstruction - self.userName = userName - self.customRules = customRules - self.extendedContext = extendedContext - self.languageInstruction = languageInstruction - self.clipboardContext = clipboardContext - self.visualContextSummary = visualContextSummary - self.surfaceContext = surfaceContext - self.isMultiLineEnabled = isMultiLineEnabled - self.requestID = requestID - } -} - -/// The engine's normalized response, including raw model text for debugging. -struct SuggestionResult: Equatable, Sendable { - let generation: UInt64 - let rawText: String - let text: String - let latency: TimeInterval - /// Raw value of the `CompletionSuppressionReason` that emptied `text`, when one applies. - /// Carried as a string so the coordinator's quality accounting never needs the normalizer - /// type, and so engine-specific reasons can ride along without enum churn. The explicit - /// initializer default keeps existing call sites compiling unchanged. - let suppressionReason: String? - - init( - generation: UInt64, - rawText: String, - text: String, - latency: TimeInterval, - suppressionReason: String? = nil - ) { - self.generation = generation - self.rawText = rawText - self.text = text - self.latency = latency - self.suppressionReason = suppressionReason - } -} - -/// Represents one active inline-completion session after the model has produced a suggestion. -/// The key architectural shift is that a suggestion is no longer "fire once and forget." -/// Instead, it becomes durable interaction state that can be partially consumed over time. -struct ActiveSuggestionSession: Equatable, Sendable { - /// The focused field state that produced the original suggestion. - /// We keep this as the anchor so later text changes can be interpreted as: - /// "user consumed part of the suggestion" vs "user diverged from it." - let baseContext: FocusedInputContext - let fullText: String - let consumedCharacterCount: Int - let latency: TimeInterval - /// `.continuation` for normal forward suggestions; `.correction(typoWord:)` when the session - /// represents a typo fix. The acceptance path branches on this so corrections always commit the - /// whole word and replace the typo rather than appending forward text. - let kind: SuggestionKind - - init( - baseContext: FocusedInputContext, - fullText: String, - consumedCharacterCount: Int = 0, - latency: TimeInterval, - kind: SuggestionKind = .continuation - ) { - self.baseContext = baseContext - self.fullText = fullText - self.consumedCharacterCount = min(max(consumedCharacterCount, 0), fullText.count) - self.latency = latency - self.kind = kind - } - - var acceptedText: String { - fullText.leadingCharacters(consumedCharacterCount) - } - - var remainingText: String { - fullText.droppingLeadingCharacters(consumedCharacterCount) - } - - var acceptedCount: Int { - consumedCharacterCount - } - - var remainingCount: Int { - remainingText.count - } - - /// A whitespace-only tail is effectively exhausted for inline UX. - /// Showing "ghost spaces" is visually confusing and not worth preserving. - var isExhausted: Bool { - remainingText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - /// Returns a new session advanced by the accepted or typed character count. - /// The original value stays unchanged because this type models immutable interaction state. - func advancing(by consumedCharacters: Int) -> ActiveSuggestionSession { - ActiveSuggestionSession( - baseContext: baseContext, - fullText: fullText, - consumedCharacterCount: self.consumedCharacterCount + max(consumedCharacters, 0), - latency: latency, - kind: kind - ) - } - - /// Rebuilds the session from a fully observed live editor state during reconciliation. - /// This is useful when AX catches up after optimistic UI updates such as partial Tab accepts. - func withConsumedCharacters(_ consumedCharacters: Int) -> ActiveSuggestionSession { - ActiveSuggestionSession( - baseContext: baseContext, - fullText: fullText, - consumedCharacterCount: consumedCharacters, - latency: latency, - kind: kind - ) - } -} - -/// Records the chunk committed by the most recent full acceptance and the field text it was -/// appended after. The coordinator stamps this on a final-chunk accept and consumes it on the next -/// generation. If the model only re-proposes `text` while the live preceding text still equals -/// `precedingText`, the host has not published our insert yet (the Chromium AX-publish race), so the -/// suggestion is dropped instead of looping accept/regenerate/accept on the last word. -struct AcceptedSuggestionTail: Equatable, Sendable { - let text: String - let precedingText: String -} - -/// High-level suggestion states surfaced to the menu and overlay logic. -enum SuggestionDebugState: Equatable { - case idle - case disabled(String) - case debouncing - case generating - case ready(text: String, latency: TimeInterval) - case failed(String) - - var shortLabel: String { - switch self { - case .idle: - return "Idle" - case .disabled: - return "Disabled" - case .debouncing: - return "Debouncing" - case .generating: - return "Generating" - case .ready: - return "Ready" - case .failed: - return "Failed" - } - } - - var detail: String? { - switch self { - case .idle: - return "No active suggestion is currently available." - case let .disabled(reason), let .failed(reason): - return reason - case .debouncing: - return "Waiting for typing to settle." - case .generating: - return "Requesting a completion from the active suggestion backend." - case .ready: - return "Ready means Cotabby has buffered a non-empty normalized completion for this field and can render it as ghost text." - } - } -} - -/// Geometry needed to render ghost text in the same visual line box as the host editor. -/// -/// `caretRect` tells Cotabby where the current insertion point is. `inputFrameRect` gives the -/// broader editor bounds, which lets the overlay wrap overflow text back to the field's left edge -/// instead of drawing past the right edge of the text container. -struct SuggestionOverlayGeometry: Equatable, Sendable { - let caretRect: CGRect - let inputFrameRect: CGRect? - let caretQuality: CaretGeometryQuality - /// True when the caret is at the end of its line: only whitespace, if anything, precedes the - /// next line break. When false, real characters follow the caret on this line, so the - /// render-mode policy promotes the suggestion to the card: inline ghost text would otherwise - /// paint over those trailing characters. Carried from `FocusedInputContext.isCaretAtEndOfLine`. - /// Defaults to `true` so call sites that predate the mid-line rule keep the prior inline path. - let isCaretAtEndOfLine: Bool - /// Average character width from AX child-frame sampling when available. Layout uses this as a - /// cheap approximation for host-editor text width before falling back to local font metrics. - let observedCharWidth: CGFloat? - /// When `true`, the text near the caret is Right-to-Left (Arabic, Hebrew, etc.) and the ghost - /// text overlay should appear to the left of the caret instead of the right. - let isRightToLeft: Bool - /// Identifies the focus session that produced this geometry. `OverlayController` keys its - /// per-session font-size stabilization on this value, so a field switch (or focus loss) starts - /// a fresh size baseline. Defaults to 0 for tests that do not exercise session-scoped behavior. - let focusChangeSequence: UInt64 - /// Stable identity for the focused input field, used to scope ghost-font stabilization. - /// Unlike `focusChangeSequence`, this does NOT change when the field resizes (e.g., a chat - /// composer growing taller as text wraps), so the stabilizer's per-session minimum survives - /// self-growing inputs. It DOES change when the user focuses a genuinely different field. - /// Defaults to 0 for tests that do not exercise session-scoped behavior. - let focusedInputIdentityKey: UInt64 - /// When `true`, the overlay is rendering a typo correction rather than a forward continuation. - /// `OverlayController` switches to a green tint on this signal so the user can tell at a glance - /// that pressing the accept key will replace their last word, not extend it. - let isCorrection: Bool - /// The host field's own text font/color, so the overlay can render ghost text that matches the - /// field instead of always using the system font and a fixed gray. Nil falls back to defaults. - let resolvedFieldStyle: ResolvedFieldStyle? - - init( - caretRect: CGRect, - inputFrameRect: CGRect?, - caretQuality: CaretGeometryQuality, - isCaretAtEndOfLine: Bool = true, - observedCharWidth: CGFloat?, - isRightToLeft: Bool, - focusChangeSequence: UInt64 = 0, - focusedInputIdentityKey: UInt64 = 0, - isCorrection: Bool = false, - resolvedFieldStyle: ResolvedFieldStyle? = nil - ) { - self.caretRect = caretRect - self.inputFrameRect = inputFrameRect - self.caretQuality = caretQuality - self.isCaretAtEndOfLine = isCaretAtEndOfLine - self.observedCharWidth = observedCharWidth - self.isRightToLeft = isRightToLeft - self.focusChangeSequence = focusChangeSequence - self.focusedInputIdentityKey = focusedInputIdentityKey - self.isCorrection = isCorrection - self.resolvedFieldStyle = resolvedFieldStyle - } - - /// Returns a copy with only `caretRect` replaced. Used to advance the ghost by an exact measured - /// width on word acceptance without re-reading (and re-jittering against) a fresh AX caret. - func withCaretRect(_ caretRect: CGRect) -> SuggestionOverlayGeometry { - SuggestionOverlayGeometry( - caretRect: caretRect, - inputFrameRect: inputFrameRect, - caretQuality: caretQuality, - isCaretAtEndOfLine: isCaretAtEndOfLine, - observedCharWidth: observedCharWidth, - isRightToLeft: isRightToLeft, - focusChangeSequence: focusChangeSequence, - focusedInputIdentityKey: focusedInputIdentityKey, - resolvedFieldStyle: resolvedFieldStyle - ) - } -} - -/// The overlay is intentionally modeled as data so diagnostics can reason about visibility -/// without poking into AppKit window objects directly. -/// -/// `visible` carries the active `CompletionRenderMode` so the focus debug overlay, tests, and -/// presenter state-diffing can distinguish an inline ghost from a mirror card without inspecting -/// `OverlayController` internals. -enum OverlayState: Equatable { - case hidden(reason: String) - case visible(text: String, geometry: SuggestionOverlayGeometry, mode: CompletionRenderMode) - - var shortLabel: String { - switch self { - case .hidden: - return "Hidden" - case .visible: - return "Visible" - } - } - - var detail: String { - switch self { - case let .hidden(reason): - return reason - case let .visible(text, geometry, mode): - return "Showing \(text.count) characters near " + - "(\(Int(geometry.caretRect.minX)), \(Int(geometry.caretRect.minY))) " + - "using \(geometry.caretQuality.label) caret geometry (\(mode.label))." - } - } - - var isVisible: Bool { - if case .visible = self { - return true - } - - return false - } - - var visibleText: String? { - guard case let .visible(text, _, _) = self else { - return nil - } - - return text - } - - var visibleMode: CompletionRenderMode? { - guard case let .visible(_, _, mode) = self else { - return nil - } - return mode - } -} - -/// Errors specific to suggestion generation and normalization. -enum SuggestionClientError: LocalizedError { - case unavailable(String) - case unsupportedLanguageOrLocale(String) - case generationFailed(String) - case cancelled - - var errorDescription: String? { - switch self { - case let .unavailable(message), - let .unsupportedLanguageOrLocale(message), - let .generationFailed(message): - return message - case .cancelled: - return "Generation was cancelled." - } - } -} - -private extension String { - /// Swift `String` is a collection of extended grapheme clusters, not bytes. - /// These helpers slice by user-visible characters so emoji and composed characters stay intact. - /// That matters because autocomplete acceptance is a user-facing action, not a byte-level one. - func leadingCharacters(_ count: Int) -> String { - String(prefix(max(count, 0))) - } - - func droppingLeadingCharacters(_ count: Int) -> String { - String(dropFirst(max(count, 0))) - } -} diff --git a/Cotabby/Models/Suggestion/SuggestionPresentationModels.swift b/Cotabby/Models/Suggestion/SuggestionPresentationModels.swift new file mode 100644 index 00000000..9b853456 --- /dev/null +++ b/Cotabby/Models/Suggestion/SuggestionPresentationModels.swift @@ -0,0 +1,180 @@ +import CoreGraphics +import Foundation + +/// Presentation-facing suggestion state shared by the coordinator, presenter, overlays, and diagnostics. + +/// High-level suggestion states surfaced to the menu and overlay logic. +enum SuggestionDebugState: Equatable { + case idle + case disabled(String) + case debouncing + case generating + case ready(text: String, latency: TimeInterval) + case failed(String) + + var shortLabel: String { + switch self { + case .idle: + return "Idle" + case .disabled: + return "Disabled" + case .debouncing: + return "Debouncing" + case .generating: + return "Generating" + case .ready: + return "Ready" + case .failed: + return "Failed" + } + } + + var detail: String? { + switch self { + case .idle: + return "No active suggestion is currently available." + case let .disabled(reason), let .failed(reason): + return reason + case .debouncing: + return "Waiting for typing to settle." + case .generating: + return "Requesting a completion from the active suggestion backend." + case .ready: + return "Ready means Cotabby has buffered a non-empty normalized completion for this field and can render it as ghost text." + } + } +} + +/// Geometry needed to render ghost text in the same visual line box as the host editor. +/// +/// `caretRect` tells Cotabby where the current insertion point is. `inputFrameRect` gives the +/// broader editor bounds, which lets the overlay wrap overflow text back to the field's left edge +/// instead of drawing past the right edge of the text container. +struct SuggestionOverlayGeometry: Equatable, Sendable { + let caretRect: CGRect + let inputFrameRect: CGRect? + let caretQuality: CaretGeometryQuality + /// True when the caret is at the end of its line: only whitespace, if anything, precedes the + /// next line break. When false, real characters follow the caret on this line, so the + /// render-mode policy promotes the suggestion to the card: inline ghost text would otherwise + /// paint over those trailing characters. Carried from `FocusedInputContext.isCaretAtEndOfLine`. + /// Defaults to `true` so call sites that predate the mid-line rule keep the prior inline path. + let isCaretAtEndOfLine: Bool + /// Average character width from AX child-frame sampling when available. Layout uses this as a + /// cheap approximation for host-editor text width before falling back to local font metrics. + let observedCharWidth: CGFloat? + /// When `true`, the text near the caret is Right-to-Left (Arabic, Hebrew, etc.) and the ghost + /// text overlay should appear to the left of the caret instead of the right. + let isRightToLeft: Bool + /// Identifies the focus session that produced this geometry. `OverlayController` keys its + /// per-session font-size stabilization on this value, so a field switch (or focus loss) starts + /// a fresh size baseline. Defaults to 0 for tests that do not exercise session-scoped behavior. + let focusChangeSequence: UInt64 + /// Stable identity for the focused input field, used to scope ghost-font stabilization. + /// Unlike `focusChangeSequence`, this does NOT change when the field resizes (e.g., a chat + /// composer growing taller as text wraps), so the stabilizer's per-session minimum survives + /// self-growing inputs. It DOES change when the user focuses a genuinely different field. + /// Defaults to 0 for tests that do not exercise session-scoped behavior. + let focusedInputIdentityKey: UInt64 + /// When `true`, the overlay is rendering a typo correction rather than a forward continuation. + /// `OverlayController` switches to a green tint on this signal so the user can tell at a glance + /// that pressing the accept key will replace their last word, not extend it. + let isCorrection: Bool + /// The host field's own text font/color, so the overlay can render ghost text that matches the + /// field instead of always using the system font and a fixed gray. Nil falls back to defaults. + let resolvedFieldStyle: ResolvedFieldStyle? + + init( + caretRect: CGRect, + inputFrameRect: CGRect?, + caretQuality: CaretGeometryQuality, + isCaretAtEndOfLine: Bool = true, + observedCharWidth: CGFloat?, + isRightToLeft: Bool, + focusChangeSequence: UInt64 = 0, + focusedInputIdentityKey: UInt64 = 0, + isCorrection: Bool = false, + resolvedFieldStyle: ResolvedFieldStyle? = nil + ) { + self.caretRect = caretRect + self.inputFrameRect = inputFrameRect + self.caretQuality = caretQuality + self.isCaretAtEndOfLine = isCaretAtEndOfLine + self.observedCharWidth = observedCharWidth + self.isRightToLeft = isRightToLeft + self.focusChangeSequence = focusChangeSequence + self.focusedInputIdentityKey = focusedInputIdentityKey + self.isCorrection = isCorrection + self.resolvedFieldStyle = resolvedFieldStyle + } + + /// Returns a copy with only `caretRect` replaced. Used to advance the ghost by an exact measured + /// width on word acceptance without re-reading (and re-jittering against) a fresh AX caret. + func withCaretRect(_ caretRect: CGRect) -> SuggestionOverlayGeometry { + SuggestionOverlayGeometry( + caretRect: caretRect, + inputFrameRect: inputFrameRect, + caretQuality: caretQuality, + isCaretAtEndOfLine: isCaretAtEndOfLine, + observedCharWidth: observedCharWidth, + isRightToLeft: isRightToLeft, + focusChangeSequence: focusChangeSequence, + focusedInputIdentityKey: focusedInputIdentityKey, + resolvedFieldStyle: resolvedFieldStyle + ) + } +} + +/// The overlay is intentionally modeled as data so diagnostics can reason about visibility +/// without poking into AppKit window objects directly. +/// +/// `visible` carries the active `CompletionRenderMode` so the focus debug overlay, tests, and +/// presenter state-diffing can distinguish an inline ghost from a mirror card without inspecting +/// `OverlayController` internals. +enum OverlayState: Equatable { + case hidden(reason: String) + case visible(text: String, geometry: SuggestionOverlayGeometry, mode: CompletionRenderMode) + + var shortLabel: String { + switch self { + case .hidden: + return "Hidden" + case .visible: + return "Visible" + } + } + + var detail: String { + switch self { + case let .hidden(reason): + return reason + case let .visible(text, geometry, mode): + return "Showing \(text.count) characters near " + + "(\(Int(geometry.caretRect.minX)), \(Int(geometry.caretRect.minY))) " + + "using \(geometry.caretQuality.label) caret geometry (\(mode.label))." + } + } + + var isVisible: Bool { + if case .visible = self { + return true + } + + return false + } + + var visibleText: String? { + guard case let .visible(text, _, _) = self else { + return nil + } + + return text + } + + var visibleMode: CompletionRenderMode? { + guard case let .visible(_, _, mode) = self else { + return nil + } + return mode + } +} diff --git a/Cotabby/Models/Suggestion/SuggestionRequest.swift b/Cotabby/Models/Suggestion/SuggestionRequest.swift new file mode 100644 index 00000000..ab4ee9c0 --- /dev/null +++ b/Cotabby/Models/Suggestion/SuggestionRequest.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Immutable request values shared across Apple Intelligence, llama.cpp, and endpoint backends. +/// Engines receive this snapshot instead of consulting mutable settings or live editor state. + +/// Distinguishes a normal forward continuation from a typo-correction reply. +/// +/// `.correction(typoWord:)` carries the exact word the correction was offered for, so the acceptance +/// path can confirm the live field still ends with that word before deleting it (guarding against a +/// keystroke that slipped in between), then replace it with the corrected word. This lets a +/// correction reuse the same session/overlay machinery as a plain continuation while committing as a +/// whole-word replacement. +enum SuggestionKind: Equatable, Sendable { + case continuation + case correction(typoWord: String) + + var isCorrection: Bool { + if case .correction = self { + return true + } + return false + } +} + +/// One generation request sent from the coordinator into the suggestion engine. +struct SuggestionRequest: Equatable, Sendable { + let context: FocusedInputContext + /// The truncated text immediately before the caret. + /// This stays backend-agnostic and gives every engine access to the same local writing context + /// even if they render prompts differently. + let prefixText: String + /// The canonical prompt payload for prompt-oriented backends such as the local llama runtime. + /// Engines that prefer a separate instructions channel can derive their own request text from + /// `prefixText` and the other shared fields instead of consuming this string directly. + let prompt: String + let generation: UInt64 + let maxPredictionTokens: Int + let temperature: Double + let topK: Int + let topP: Double + let minP: Double + let repetitionPenalty: Double + /// Optional deterministic sampler seed. `nil` preserves production randomness; tests and + /// microbenches can set this so cached and uncached runtime paths are directly comparable. + let randomSeed: UInt32? + let maxSuffixCharacters: Int + /// Explicit length guidance stays separate from user style preferences so prompt builders can + /// order and phrase them differently per backend. + let completionLengthInstruction: String + /// Optional user-provided profile context. We keep this separate from base product behavior so + /// future settings/personalization work can evolve independently from prompt safety rules. + let userName: String? + /// User-authored style rules rendered as additional prompt directives, subordinate to the base + /// autocomplete/safety rules. Empty when the user has none. + let customRules: [String] + /// User-authored free-form context (glossary, jargon, style notes) injected verbatim into the + /// prompt. Already trimmed and length-capped upstream so renderers can treat it as a ready-to-use + /// string. `nil` when the user has not set it, distinguishing it from an empty-but-set value so + /// renderers can skip the heading entirely. + let extendedContext: String? + /// Pre-rendered language hint built from the user's declared languages (e.g. "The user usually + /// writes in German and English…"). `nil` when none are declared. Deliberately a hint, not an + /// override: it tells the model to match the surrounding text and only fall back to the declared + /// languages when that text is ambiguous, which protects code-switching. + let languageInstruction: String? + /// Ephemeral clipboard context captured only when the user has enabled clipboard prompting. + let clipboardContext: String? + /// Ephemeral screen context summary injected only when available for the active text field. + let visualContextSummary: String? + /// The composed writing-surface description (app class, window title, domain, placeholder), + /// nil when the user disabled surface context or the surface class suppresses it. The llama + /// 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? + /// When enabled, the normalizer keeps multiple lines instead of truncating to the first line. + let isMultiLineEnabled: Bool + /// Correlation ID stamped onto every log line touching this request — coordinator state + /// transitions, router selection, engine generation, LLM I/O capture, insertion. Generated by + /// `RequestID.generate()` in `SuggestionRequestFactory`. Defaulted in the init so test fixtures + /// that build requests directly do not need to change. + let requestID: String + + init( + context: FocusedInputContext, + prefixText: String, + prompt: String, + generation: UInt64, + maxPredictionTokens: Int, + temperature: Double, + topK: Int, + topP: Double, + minP: Double, + repetitionPenalty: Double, + randomSeed: UInt32?, + maxSuffixCharacters: Int, + completionLengthInstruction: String, + userName: String?, + customRules: [String], + extendedContext: String? = nil, + languageInstruction: String?, + clipboardContext: String?, + visualContextSummary: String?, + surfaceContext: SurfaceContext? = nil, + isMultiLineEnabled: Bool, + requestID: String = "req_unknown" + ) { + self.context = context + self.prefixText = prefixText + self.prompt = prompt + self.generation = generation + self.maxPredictionTokens = maxPredictionTokens + self.temperature = temperature + self.topK = topK + self.topP = topP + self.minP = minP + self.repetitionPenalty = repetitionPenalty + self.randomSeed = randomSeed + self.maxSuffixCharacters = maxSuffixCharacters + self.completionLengthInstruction = completionLengthInstruction + self.userName = userName + self.customRules = customRules + self.extendedContext = extendedContext + self.languageInstruction = languageInstruction + self.clipboardContext = clipboardContext + self.visualContextSummary = visualContextSummary + self.surfaceContext = surfaceContext + self.isMultiLineEnabled = isMultiLineEnabled + self.requestID = requestID + } +} diff --git a/Cotabby/Models/Suggestion/SuggestionResult.swift b/Cotabby/Models/Suggestion/SuggestionResult.swift new file mode 100644 index 00000000..8fa44360 --- /dev/null +++ b/Cotabby/Models/Suggestion/SuggestionResult.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Backend-independent generation output returned to the suggestion coordinator. + +/// The engine's normalized response, including raw model text for debugging. +struct SuggestionResult: Equatable, Sendable { + let generation: UInt64 + let rawText: String + let text: String + let latency: TimeInterval + /// Raw value of the `CompletionSuppressionReason` that emptied `text`, when one applies. + /// Carried as a string so the coordinator's quality accounting never needs the normalizer + /// type, and so engine-specific reasons can ride along without enum churn. The explicit + /// initializer default keeps existing call sites compiling unchanged. + let suppressionReason: String? + + init( + generation: UInt64, + rawText: String, + text: String, + latency: TimeInterval, + suppressionReason: String? = nil + ) { + self.generation = generation + self.rawText = rawText + self.text = text + self.latency = latency + self.suppressionReason = suppressionReason + } +} diff --git a/Cotabby/Services/Presentation/OverlayController.swift b/Cotabby/Services/Presentation/OverlayController.swift index 00c8a17a..12e4a2a5 100644 --- a/Cotabby/Services/Presentation/OverlayController.swift +++ b/Cotabby/Services/Presentation/OverlayController.swift @@ -489,212 +489,3 @@ private final class OverlayPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } } - -/// The green used to signal a typo correction. Tuned per color scheme so it stays legible in both -/// appearances without dropping below a comfortable contrast floor against typical text-field -/// backgrounds. Shared by the inline ghost and the mirror card so a correction reads identically in -/// either display mode; the user's custom suggestion color is intentionally bypassed for corrections -/// because semantic communication beats personalization here. -private enum SuggestionCorrectionStyle { - static func color(for colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.45, green: 0.85, blue: 0.45) - : Color(red: 0.15, green: 0.60, blue: 0.20) - } -} - -/// Small SwiftUI view hosted inside the floating AppKit panel. -/// Keeping the rendered content separate from the window controller makes styling easier to evolve -/// without touching the AppKit positioning code. -private struct GhostSuggestionView: View { - @Environment(\.colorScheme) var colorScheme - let layout: GhostSuggestionLayout - let fontSize: CGFloat - /// The host field's font at the rendered size, or nil to use the system font at `fontSize`. - let fieldFont: NSFont? - /// The host field's foreground color mapped to a ghost color, or nil to use the default gray. - let fieldColor: Color? - let customColor: Color? - /// The accept key to print inside the keycap pill, or `nil` when the hint is suppressed. Pairs - /// with `layout.lines`, where `showsKeycap` is already false on every line when this is `nil`. - let keycapLabel: String? - /// User-controlled fade for the suggestion text, in [0.3, 1.0]. Applied only to the ghost text, - /// not the keycap, so the acceptance hint stays legible at low opacities. - let opacity: Double - /// When true, the suggestion is replacing a typo'd word. We render in green to signal that - /// accepting will swap the user's last word, not extend it. The custom color override is - /// intentionally bypassed in this mode: semantic communication beats personalization here. - let isCorrection: Bool - - /// Priority: explicit user override, then the host field's color, then the default gray. The - /// field color is pre-filtered upstream so invisible extremes already fall back to nil here. - var ghostColor: Color { - if isCorrection { - return SuggestionCorrectionStyle.color(for: colorScheme).opacity(opacity) - } - let baseColor = customColor - ?? fieldColor - ?? ( - colorScheme == .dark - ? Color(red: 0.65, green: 0.65, blue: 0.65) - : Color(red: 0.45, green: 0.45, blue: 0.45) - ) - return baseColor.opacity(opacity) - } - - /// The host field's typeface when known, otherwise the system font at the derived size. - private var resolvedFont: Font { - if let fieldFont { - return Font(fieldFont as CTFont) - } - return .system(size: fontSize) - } - - var body: some View { - let alignment: HorizontalAlignment = layout.isRightToLeft ? .trailing : .leading - VStack(alignment: alignment, spacing: 0) { - ForEach(layout.lines) { line in - let showsKeycap = line.showsKeycap && keycapLabel != nil - HStack(alignment: .firstTextBaseline, spacing: showsKeycap ? 6 : 0) { - if layout.isRightToLeft, showsKeycap, let keycapLabel { - GhostKeycap(label: keycapLabel) - } - - Text(line.text) - .font(resolvedFont) - .foregroundStyle(ghostColor) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: true) - - if !layout.isRightToLeft, showsKeycap, let keycapLabel { - GhostKeycap(label: keycapLabel) - } - } - .padding(layout.isRightToLeft ? .trailing : .leading, line.leadingIndent) - .fixedSize(horizontal: true, vertical: true) - } - } - .fixedSize(horizontal: true, vertical: true) - } -} - -/// Visual hint that teaches the user which key accepts the suggestion. The label tracks the user's -/// configured accept keybind, so rebinding away from Tab updates the pill instead of lying about it. -private struct GhostKeycap: View { - @Environment(\.colorScheme) var colorScheme - let label: String - - var textColor: Color { - colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.45) - } - - var bgColor: Color { - colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.95) - } - - var borderColor: Color { - colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.8) - } - - var body: some View { - Text(label) - .font(.system(size: 10, weight: .medium, design: .rounded)) - .foregroundStyle(textColor) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(bgColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(borderColor, lineWidth: 1) - ) - .fixedSize(horizontal: true, vertical: true) - } -} - -/// Mirror-mode card. Renders the suggestion inside a Cotabby-owned backdrop anchored below the -/// focused field. Unlike `GhostSuggestionView`, this view is single-line by design — the whole -/// reason mirror mode exists is that the host's caret rect is unreliable, so multi-line wrapping -/// would just compound the positioning uncertainty. -/// -/// The backdrop and shadow are what make this read as a deliberate UI element instead of a stray -/// floating text label. We do not try to disguise the card as the host editor; Cotypist's product -/// language ("preview") is the right framing here. -private struct MirrorOverlayView: View { - let layout: MirrorOverlayLayout - let customColor: Color? - let keycapLabel: String? - let opacity: Double - /// When true, the suggestion replaces a typo'd word; the whole card lights up green to match the - /// inline ghost, and the next-accept-word highlight is suppressed (the correction is one unit). - let isCorrection: Bool - - /// The card is now a committed-dark HUD in every host appearance, so the suggestion text is - /// always the light-on-dark value rather than branching on the system scheme (which would bake - /// dark-gray text onto the dark card over a light host). The correction green likewise uses the - /// dark-appropriate tone. - private var ghostColor: Color { - if isCorrection { - return SuggestionCorrectionStyle.color(for: .dark).opacity(opacity) - } - let baseColor = customColor ?? Color(red: 0.85, green: 0.85, blue: 0.85) - return baseColor.opacity(opacity) - } - - /// The next-accept word renders at full strength so it reads as "this is what Tab takes next." - /// The user's custom suggestion color (if set) is honored at full opacity; otherwise the primary - /// label color keeps strong contrast against the card backdrop in both appearances. - private var highlightColor: Color { - customColor ?? .primary - } - - /// The suggestion as one attributed run: the highlighted prefix (the next accept-word) is drawn - /// full-strength and semibold so it "lights up" as the word being completed, while the rest keeps - /// the muted ghost color. Building one `AttributedString` instead of two `Text`s keeps the card on - /// a single line and lets tail-truncation treat the whole suggestion as one unit. - private var styledSuggestion: AttributedString { - var attributed = AttributedString(layout.suggestionText) - attributed.font = .system(size: layout.fontSize) - attributed.foregroundColor = ghostColor - - // A correction replaces the whole word, so the entire run stays green; the next-accept-word - // highlight (which marks where Tab stops mid-completion) does not apply to a correction. - guard !isCorrection else { return attributed } - - let prefix = layout.highlightedPrefix - guard !prefix.isEmpty, layout.suggestionText.hasPrefix(prefix) else { - return attributed - } - let characters = attributed.characters - let highlightEnd = characters.index(characters.startIndex, offsetBy: prefix.count) - let highlightRange = characters.startIndex.. Color { + colorScheme == .dark + ? Color(red: 0.45, green: 0.85, blue: 0.45) + : Color(red: 0.15, green: 0.60, blue: 0.20) + } +} + +/// Small SwiftUI view hosted inside the floating AppKit panel. +/// Keeping the rendered content separate from the window controller makes styling easier to evolve +/// without touching the AppKit positioning code. +struct GhostSuggestionView: View { + @Environment(\.colorScheme) var colorScheme + let layout: GhostSuggestionLayout + let fontSize: CGFloat + /// The host field's font at the rendered size, or nil to use the system font at `fontSize`. + let fieldFont: NSFont? + /// The host field's foreground color mapped to a ghost color, or nil to use the default gray. + let fieldColor: Color? + let customColor: Color? + /// The accept key to print inside the keycap pill, or `nil` when the hint is suppressed. Pairs + /// with `layout.lines`, where `showsKeycap` is already false on every line when this is `nil`. + let keycapLabel: String? + /// User-controlled fade for the suggestion text, in [0.3, 1.0]. Applied only to the ghost text, + /// not the keycap, so the acceptance hint stays legible at low opacities. + let opacity: Double + /// When true, the suggestion is replacing a typo'd word. We render in green to signal that + /// accepting will swap the user's last word, not extend it. The custom color override is + /// intentionally bypassed in this mode: semantic communication beats personalization here. + let isCorrection: Bool + + /// Priority: explicit user override, then the host field's color, then the default gray. The + /// field color is pre-filtered upstream so invisible extremes already fall back to nil here. + var ghostColor: Color { + if isCorrection { + return SuggestionCorrectionStyle.color(for: colorScheme).opacity(opacity) + } + let baseColor = customColor + ?? fieldColor + ?? ( + colorScheme == .dark + ? Color(red: 0.65, green: 0.65, blue: 0.65) + : Color(red: 0.45, green: 0.45, blue: 0.45) + ) + return baseColor.opacity(opacity) + } + + /// The host field's typeface when known, otherwise the system font at the derived size. + private var resolvedFont: Font { + if let fieldFont { + return Font(fieldFont as CTFont) + } + return .system(size: fontSize) + } + + var body: some View { + let alignment: HorizontalAlignment = layout.isRightToLeft ? .trailing : .leading + VStack(alignment: alignment, spacing: 0) { + ForEach(layout.lines) { line in + let showsKeycap = line.showsKeycap && keycapLabel != nil + HStack(alignment: .firstTextBaseline, spacing: showsKeycap ? 6 : 0) { + if layout.isRightToLeft, showsKeycap, let keycapLabel { + GhostKeycap(label: keycapLabel) + } + + Text(line.text) + .font(resolvedFont) + .foregroundStyle(ghostColor) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: true) + + if !layout.isRightToLeft, showsKeycap, let keycapLabel { + GhostKeycap(label: keycapLabel) + } + } + .padding(layout.isRightToLeft ? .trailing : .leading, line.leadingIndent) + .fixedSize(horizontal: true, vertical: true) + } + } + .fixedSize(horizontal: true, vertical: true) + } +} + +/// Visual hint that teaches the user which key accepts the suggestion. The label tracks the user's +/// configured accept keybind, so rebinding away from Tab updates the pill instead of lying about it. +struct GhostKeycap: View { + @Environment(\.colorScheme) var colorScheme + let label: String + + var textColor: Color { + colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.45) + } + + var bgColor: Color { + colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.95) + } + + var borderColor: Color { + colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.8) + } + + var body: some View { + Text(label) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(textColor) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(bgColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(borderColor, lineWidth: 1) + ) + .fixedSize(horizontal: true, vertical: true) + } +} + +/// Mirror-mode card. Renders the suggestion inside a Cotabby-owned backdrop anchored below the +/// focused field. Unlike `GhostSuggestionView`, this view is single-line by design — the whole +/// reason mirror mode exists is that the host's caret rect is unreliable, so multi-line wrapping +/// would just compound the positioning uncertainty. +/// +/// The backdrop and shadow are what make this read as a deliberate UI element instead of a stray +/// floating text label. We do not try to disguise the card as the host editor; Cotypist's product +/// language ("preview") is the right framing here. +struct MirrorOverlayView: View { + let layout: MirrorOverlayLayout + let customColor: Color? + let keycapLabel: String? + let opacity: Double + /// When true, the suggestion replaces a typo'd word; the whole card lights up green to match the + /// inline ghost, and the next-accept-word highlight is suppressed (the correction is one unit). + let isCorrection: Bool + + /// The card is now a committed-dark HUD in every host appearance, so the suggestion text is + /// always the light-on-dark value rather than branching on the system scheme (which would bake + /// dark-gray text onto the dark card over a light host). The correction green likewise uses the + /// dark-appropriate tone. + private var ghostColor: Color { + if isCorrection { + return SuggestionCorrectionStyle.color(for: .dark).opacity(opacity) + } + let baseColor = customColor ?? Color(red: 0.85, green: 0.85, blue: 0.85) + return baseColor.opacity(opacity) + } + + /// The next-accept word renders at full strength so it reads as "this is what Tab takes next." + /// The user's custom suggestion color (if set) is honored at full opacity; otherwise the primary + /// label color keeps strong contrast against the card backdrop in both appearances. + private var highlightColor: Color { + customColor ?? .primary + } + + /// The suggestion as one attributed run: the highlighted prefix (the next accept-word) is drawn + /// full-strength and semibold so it "lights up" as the word being completed, while the rest keeps + /// the muted ghost color. Building one `AttributedString` instead of two `Text`s keeps the card on + /// a single line and lets tail-truncation treat the whole suggestion as one unit. + private var styledSuggestion: AttributedString { + var attributed = AttributedString(layout.suggestionText) + attributed.font = .system(size: layout.fontSize) + attributed.foregroundColor = ghostColor + + // A correction replaces the whole word, so the entire run stays green; the next-accept-word + // highlight (which marks where Tab stops mid-completion) does not apply to a correction. + guard !isCorrection else { return attributed } + + let prefix = layout.highlightedPrefix + guard !prefix.isEmpty, layout.suggestionText.hasPrefix(prefix) else { + return attributed + } + let characters = attributed.characters + let highlightEnd = characters.index(characters.startIndex, offsetBy: prefix.count) + let highlightRange = characters.startIndex.. { + Binding( + get: { suggestionSettings.selectedEngine }, + set: { suggestionSettings.selectEngine($0) } + ) + } + + var selectedModelBinding: Binding { + Binding( + get: { + runtimeModel.selectedModelFilename + ?? runtimeModel.availableModels.first?.filename + ?? "" + }, + set: { filename in + Task { await runtimeModel.selectModel(filename) } + } + ) + } + + var endpointBaseURLBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleBaseURL }, + set: { value in + suggestionSettings.setOpenAICompatibleBaseURL(value) + openAICompatibleConnectionModel.invalidate() + } + ) + } + + var endpointModelBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleModelName }, + set: { suggestionSettings.setOpenAICompatibleModelName($0) } + ) + } + + var endpointAPIModeBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleAPIMode }, + set: { suggestionSettings.setOpenAICompatibleAPIMode($0) } + ) + } + + var endpointPrivacyWarning: String? { + (try? suggestionSettings.openAICompatibleConfiguration)?.privacyWarning + } + + /// Compact feedback stays beside the connection action instead of repeating the URL in a second + /// card. This view has no independent lifetime; the endpoint connection model drives every state. + var endpointConnectionStatus: some View { + HStack(spacing: 6) { + Group { + if openAICompatibleConnectionModel.state == .connecting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: endpointConnectionSymbol) + .foregroundStyle(endpointConnectionColor) + } + } + .frame(width: 14) + + Text(openAICompatibleConnectionModel.state.summary) + .font(.caption.weight(.medium)) + .foregroundStyle(endpointConnectionColor) + .lineLimit(2) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Server status") + .accessibilityValue("\(openAICompatibleConnectionModel.state.summary), \(endpointDisplayURL)") + .settingsItem(.endpointStatus) + } + + var endpointConnectButtonTitle: String { + if case .ready = openAICompatibleConnectionModel.state { + return "Refresh" + } + return "Connect" + } + + var endpointConnectionSymbol: String { + switch openAICompatibleConnectionModel.state { + case .idle: return "circle.dashed" + case .connecting: return "circle.dashed" + case .ready: return "checkmark.circle.fill" + case .failed: return "exclamationmark.triangle.fill" + } + } + + var endpointConnectionColor: Color { + switch openAICompatibleConnectionModel.state { + case .idle, .connecting: return .secondary + case .ready: return .green + case .failed: return .orange + } + } + + var endpointDisplayURL: String { + if let configuration = try? suggestionSettings.openAICompatibleConfiguration { + return configuration.baseURL.absoluteString + } + let enteredURL = suggestionSettings.openAICompatibleBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + return enteredURL.isEmpty ? "No endpoint configured" : enteredURL + } + + var endpointModelMenuHelp: String { + if openAICompatibleConnectionModel.models.isEmpty { + return "Connect to load models, or enter an identifier manually." + } + return "Choose from \(openAICompatibleConnectionModel.models.count) discovered models." + } + + var pendingDeletionAlertBinding: Binding { + Binding( + get: { pendingDeletionModel != nil }, + set: { isPresented in + if !isPresented { + pendingDeletionModel = nil + } + } + ) + } + + func deleteModel(_ model: RuntimeModelOption) { + modelDownloadManager.deleteModel(filename: model.filename) + runtimeModel.refreshAvailableModels() + pendingDeletionModel = nil + } + + func refreshModels() { + modelDownloadManager.refreshModelStates() + runtimeModel.refreshAvailableModels() + } + + /// Reset is intentionally local to the address. The selected model and Keychain credential + /// remain untouched because they may still be valid for the default Ollama server. Invalidating + /// discovery makes the status honest until the user explicitly connects again. + func resetEndpointBaseURL() { + suggestionSettings.setOpenAICompatibleBaseURL( + OpenAICompatibleEndpointConfiguration.defaultBaseURLString + ) + openAICompatibleConnectionModel.invalidate() + } + + func loadEndpointAPIKey() { + do { + endpointAPIKeyDraft = try suggestionSettings.openAICompatibleAPIKey() ?? "" + endpointCredentialError = nil + } catch { + endpointCredentialError = error.localizedDescription + } + } + + func refreshEndpointModels() { + Task { + do { + try suggestionSettings.saveOpenAICompatibleAPIKey(endpointAPIKeyDraft) + endpointCredentialError = nil + let configuration = try suggestionSettings.openAICompatibleConfiguration + await openAICompatibleConnectionModel.refresh( + configuration: configuration, + apiKey: endpointAPIKeyDraft + ) + if let preferredModel = OpenAICompatibleModelSelectionResolver.preferredSelection( + currentSelection: suggestionSettings.openAICompatibleModelName, + discoveredModels: openAICompatibleConnectionModel.models + ), preferredModel != suggestionSettings.openAICompatibleModelName { + suggestionSettings.setOpenAICompatibleModelName(preferredModel) + } + } catch { + endpointCredentialError = error.localizedDescription + openAICompatibleConnectionModel.setFailure(error.localizedDescription) + } + } + } +} diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+AppleIntelligence.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+AppleIntelligence.swift new file mode 100644 index 00000000..c3de29ef --- /dev/null +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+AppleIntelligence.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Apple Intelligence availability presentation. +/// These members are internal because Swift extensions in separate files cannot share lexical `private` access; +/// the owning view itself remains module-internal. +extension EngineAndModelPaneView { +// MARK: - Apple Intelligence + + @ViewBuilder + var appleIntelligenceSections: some View { + Section("Apple Intelligence") { + LabeledContent { + Text(foundationModelAvailabilityService.userVisibleMessage) + .foregroundStyle(foundationModelAvailabilityService.isAvailable ? .green : .orange) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } label: { + SettingsRowLabel( + title: "Availability", + description: "Whether this Mac can run Apple Intelligence. Requires a supported " + + "Apple Silicon Mac with Apple Intelligence turned on in System Settings.", + systemImage: "apple.logo" + ) + } + .settingsItem(.appleIntelligenceAvailability) + } + } +} diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Endpoint.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Endpoint.swift new file mode 100644 index 00000000..49247505 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Endpoint.swift @@ -0,0 +1,174 @@ +import SwiftUI + +/// OpenAI-compatible endpoint connection, credential, and model controls. +/// These members are internal because Swift extensions in separate files cannot share lexical `private` access; +/// the owning view itself remains module-internal. +extension EngineAndModelPaneView { +// MARK: - OpenAI-compatible endpoint + + /// Endpoint controls stay inside the unified engine pane. The connection model owns network + /// state, the settings model owns durable non-secret values, and the API key draft only lives + /// for this view's lifetime before an explicit Connect saves it to Keychain. + @ViewBuilder + var openAICompatibleSections: some View { + Section("Connection") { + VStack(alignment: .leading, spacing: 10) { + SettingsRowLabel( + title: "Server URL", + description: "The OpenAI-compatible server address. Cotabby adds /v1 when the " + + "address has no path; Ollama uses http://127.0.0.1:11434 by default.", + systemImage: "network" + ) + + HStack(spacing: 8) { + TextField( + "", + text: endpointBaseURLBinding, + prompt: Text(OpenAICompatibleEndpointConfiguration.defaultBaseURLString) + ) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .onSubmit(refreshEndpointModels) + + Button( + endpointConnectButtonTitle, + action: refreshEndpointModels + ) + .buttonStyle(.borderedProminent) + .disabled(openAICompatibleConnectionModel.state == .connecting) + } + + HStack(spacing: 8) { + endpointConnectionStatus + + Spacer(minLength: 8) + + Button("Use Ollama Default", systemImage: "arrow.counterclockwise") { + resetEndpointBaseURL() + } + .disabled( + suggestionSettings.openAICompatibleBaseURL + == OpenAICompatibleEndpointConfiguration.defaultBaseURLString + ) + } + } + .settingsItem(.endpointBaseURL) + + LabeledContent { + HStack(spacing: 8) { + SecureField("", text: $endpointAPIKeyDraft, prompt: Text("Paste API key")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(minWidth: 200, idealWidth: 260, maxWidth: 300) + + if !endpointAPIKeyDraft.isEmpty { + Button("Clear") { + endpointAPIKeyDraft = "" + do { + try suggestionSettings.saveOpenAICompatibleAPIKey(nil) + endpointCredentialError = nil + } catch { + endpointCredentialError = error.localizedDescription + } + } + } + } + } label: { + SettingsRowLabel( + title: "API Key", + description: "Optional bearer token stored in Keychain. Ollama does not require one.", + systemImage: "key" + ) + } + .settingsItem(.endpointAPIKey) + + if let warning = endpointPrivacyWarning { + SettingsCalloutView(callout: SettingsPaneCallout(tone: .warning, message: warning)) + } + if let endpointCredentialError { + SettingsCalloutView( + callout: SettingsPaneCallout(tone: .warning, message: endpointCredentialError) + ) + } + } + + Section("Model") { + LabeledContent { + HStack(spacing: 6) { + TextField("", text: endpointModelBinding, prompt: Text("Enter model identifier")) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(minWidth: 220, idealWidth: 280, maxWidth: 320) + + Menu { + ForEach(openAICompatibleConnectionModel.models) { model in + Button(model.id) { + suggestionSettings.setOpenAICompatibleModelName(model.id) + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .frame(width: 18, height: 18) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .disabled(openAICompatibleConnectionModel.models.isEmpty) + .help(endpointModelMenuHelp) + .accessibilityLabel("Choose a discovered model") + } + } label: { + SettingsRowLabel( + title: "Model", + description: "Choose a model returned by the server or enter its identifier manually.", + systemImage: "shippingbox" + ) + } + .settingsItem(.endpointModel) + + Picker(selection: endpointAPIModeBinding) { + Text("Chat Completions").tag(OpenAICompatibleAPIMode.chatCompletions) + Text("Text Completions").tag(OpenAICompatibleAPIMode.completions) + } label: { + SettingsRowLabel( + title: "API Format", + description: "Chat uses /v1/chat/completions (recommended). Text uses " + + "/v1/completions for base models that continue a raw prompt.", + systemImage: "arrow.left.arrow.right" + ) + } + .pickerStyle(.segmented) + .settingsItem(.endpointAPIMode) + } + } + + @ViewBuilder + func installedModelRow(_ model: RuntimeModelOption) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(model.displayName) + + if model.displayName != model.actualModelName { + Text(model.actualModelName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + + if model.filename == runtimeModel.selectedModelFilename { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + } else if modelDownloadManager.canDeleteModel(filename: model.filename) { + Button { + pendingDeletionModel = model + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + } + } +} diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+OpenSource.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+OpenSource.swift new file mode 100644 index 00000000..32e8b729 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+OpenSource.swift @@ -0,0 +1,126 @@ +import SwiftUI + +/// In-process GGUF runtime, model discovery, downloads, and folder controls. +/// These members are internal because Swift extensions in separate files cannot share lexical `private` access; +/// the owning view itself remains module-internal. +extension EngineAndModelPaneView { +// MARK: - Open Source + + @ViewBuilder + var openSourceSections: some View { + Section("Runtime") { + LabeledContent { + Text(runtimeModel.state.summary) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } label: { + SettingsRowLabel( + title: "Model Status", + description: "Whether the local model is loaded and ready to generate. " + + "Loading takes a few seconds the first time.", + systemImage: "info.circle" + ) + } + .settingsItem(.modelStatus) + } + + Section("Models") { + Text("Download a model or add your own below. Models are stored locally on your Mac.") + .font(.caption) + .foregroundStyle(.secondary) + + if runtimeModel.availableModels.isEmpty { + Text("No local GGUF models found. Download one below or add your own model file.") + .foregroundStyle(.secondary) + } else { + Picker(selection: selectedModelBinding) { + ForEach(runtimeModel.availableModels) { model in + Text(model.displayName).tag(model.filename) + } + } label: { + SettingsRowLabel( + title: "Selected Model", + description: suggestionSettings.isPowerBasedModelSwitchingEnabled + ? "Set automatically by power source. Use the On Battery / Plugged In " + + "pickers in the Power section, or turn off power-based switching." + : "Which downloaded model file generates suggestions. " + + "Larger models are slower but write better.", + systemImage: "shippingbox" + ) + } + // Redundant while power-based switching owns the active model: the Power section's + // per-source profile pickers are the source of truth, and any pick here would be + // reverted on the next power evaluation. + .disabled(suggestionSettings.isPowerBasedModelSwitchingEnabled) + .settingsItem(.selectedModel) + } + + DownloadableModelCatalogView( + modelDownloadManager: modelDownloadManager, + onRefreshModels: refreshModels + ) + .settingsItem(.downloadModels) + + HuggingFaceModelBrowserView( + searchService: huggingFaceSearchService, + modelDownloadManager: modelDownloadManager, + onRefreshModels: refreshModels + ) + .settingsItem(.huggingFaceBrowser) + } + + Section("Folder") { + LabeledContent { + VStack(alignment: .trailing, spacing: 8) { + Text(modelDownloadManager.modelsDirectoryPath) + .font(.callout.monospaced()) + .textSelection(.enabled) + .multilineTextAlignment(.trailing) + + HStack(spacing: 8) { + Button("Open Folder") { + modelDownloadManager.openModelsDirectory() + } + + Button("Refresh") { + refreshModels() + } + } + } + } label: { + SettingsRowLabel( + title: "Models Folder", + description: "Where downloaded model files are stored on this Mac.", + systemImage: "folder" + ) + } + .settingsItem(.modelsFolder) + + Toggle(isOn: $lmStudioSourceEnabled) { + SettingsRowLabel( + title: "Also Use LM Studio Models", + description: lmStudioModelsURL == nil + ? "Install LM Studio to load models from its library here." + : "Add models from your LM Studio library (~/.lmstudio/models) to the picker " + + "above. Downloads still save to Cotabby's own folder.", + systemImage: "square.stack.3d.up" + ) + } + .disabled(lmStudioModelsURL == nil) + .onChange(of: lmStudioSourceEnabled) { _, _ in + modelDownloadManager.refreshSearchDirectories() + refreshModels() + } + .settingsItem(.lmStudio) + } + + if !runtimeModel.availableModels.isEmpty { + Section("Installed") { + ForEach(runtimeModel.availableModels) { model in + installedModelRow(model) + } + } + } + } +} diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Power.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Power.swift new file mode 100644 index 00000000..6b3eceac --- /dev/null +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView+Power.swift @@ -0,0 +1,114 @@ +import SwiftUI + +/// Power-source profile controls shared by every engine. +/// These members are internal because Swift extensions in separate files cannot share lexical `private` access; +/// the owning view itself remains module-internal. +extension EngineAndModelPaneView { +// MARK: - Power + + /// Engine-level section (shown for any engine) that lets the user pick a different profile, + /// Apple Intelligence or a specific local model, for battery vs. plugged-in power. Apple + /// Intelligence is offered only when it is actually available on this Mac. + @ViewBuilder + var powerSection: some View { + Section("Power") { + Toggle( + isOn: Binding( + get: { suggestionSettings.isPowerBasedModelSwitchingEnabled }, + set: { suggestionSettings.setPowerBasedModelSwitchingEnabled($0) } + ) + ) { + SettingsRowLabel( + title: "Switch Based on Power Source", + description: "Use a different engine or model on battery vs. while plugged in. " + + "For example, Apple Intelligence on battery to save power and a larger local " + + "model while charging.", + systemImage: "battery.100.bolt" + ) + } + .settingsItem(.powerBasedModelSwitching) + + if suggestionSettings.isPowerBasedModelSwitchingEnabled { + powerProfilePicker( + title: "On Battery", + systemImage: "battery.25", + selection: batteryProfileBinding + ) + .settingsItem(.batteryModel) + + powerProfilePicker( + title: "Plugged In", + systemImage: "powerplug", + selection: pluggedInProfileBinding + ) + .settingsItem(.pluggedInModel) + } + } + } + + /// One per-power-source profile picker. Lists Apple Intelligence (only when available) plus every + /// installed local model, tagged by `PowerProfile` so a single selection carries engine + model. + @ViewBuilder + func powerProfilePicker( + title: String, + systemImage: String, + selection: Binding + ) -> some View { + Picker(selection: selection) { + if foundationModelAvailabilityService.isAvailable { + Text("Apple Intelligence").tag(PowerProfile.appleIntelligence) + } + + ForEach(runtimeModel.availableModels) { model in + Text(model.displayName).tag(PowerProfile.llama(filename: model.filename)) + } + + ForEach(endpointPowerModels, id: \.self) { modelName in + Text("Endpoint · \(modelName)").tag(PowerProfile.openAICompatible(modelName: modelName)) + } + } label: { + SettingsRowLabel( + title: title, + description: "Engine and model to use while on this power source.", + systemImage: systemImage + ) + } + .pickerStyle(.menu) + } + + var batteryProfileBinding: Binding { + Binding( + get: { powerProfileForDisplay(suggestionSettings.batteryProfile) }, + set: { suggestionSettings.setBatteryProfile($0) } + ) + } + + var pluggedInProfileBinding: Binding { + Binding( + get: { powerProfileForDisplay(suggestionSettings.pluggedInProfile) }, + set: { suggestionSettings.setPluggedInProfile($0) } + ) + } + + /// Falls a not-yet-chosen local profile back to the currently selected model so the picker shows + /// a concrete row instead of an empty selection, mirroring the primary model picker's fallback. + func powerProfileForDisplay(_ profile: PowerProfile) -> PowerProfile { + if case .llama(let filename) = profile, filename.isEmpty { + return .llama(filename: runtimeModel.selectedModelFilename ?? "") + } + if case .openAICompatible(let modelName) = profile, modelName.isEmpty { + return .openAICompatible(modelName: suggestionSettings.openAICompatibleModelName) + } + + return profile + } + + var endpointPowerModels: [String] { + var names = openAICompatibleConnectionModel.models.map(\.id) + let configured = suggestionSettings.openAICompatibleModelName + if !configured.isEmpty, !names.contains(configured) { + names.append(configured) + } + return names + } +} diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift index f3448468..abcabdcb 100644 --- a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift @@ -14,16 +14,16 @@ struct EngineAndModelPaneView: View { @ObservedObject var modelDownloadManager: ModelDownloadManager @ObservedObject var huggingFaceSearchService: HuggingFaceSearchService - @State private var pendingDeletionModel: RuntimeModelOption? - @State private var endpointAPIKeyDraft = "" - @State private var endpointCredentialError: String? + @State var pendingDeletionModel: RuntimeModelOption? + @State var endpointAPIKeyDraft = "" + @State var endpointCredentialError: String? /// The LM Studio models directory if it exists, probed once in `onAppear` so the filesystem /// `fileExists` check never runs on the SwiftUI render path. Nil disables the LM Studio toggle. - @State private var lmStudioModelsURL: URL? + @State var lmStudioModelsURL: URL? /// Whether to also scan the user's LM Studio library. Persisted via the same key the locator /// reads, so the toggle and the model scan stay in sync. LM Studio models are an additive, /// read-only source; Cotabby's own folder is always scanned and is always the download target. - @AppStorage(BundledRuntimeLocator.lmStudioSourceEnabledKey) private var lmStudioSourceEnabled = false + @AppStorage(BundledRuntimeLocator.lmStudioSourceEnabledKey) var lmStudioSourceEnabled = false var body: some View { SettingsPaneScaffold(callout: callout) { @@ -96,635 +96,4 @@ struct EngineAndModelPaneView: View { Text("Remove \(model.displayName) from Cotabby's local models folder?") } } - - // MARK: - Power - - /// Engine-level section (shown for any engine) that lets the user pick a different profile, - /// Apple Intelligence or a specific local model, for battery vs. plugged-in power. Apple - /// Intelligence is offered only when it is actually available on this Mac. - @ViewBuilder - private var powerSection: some View { - Section("Power") { - Toggle( - isOn: Binding( - get: { suggestionSettings.isPowerBasedModelSwitchingEnabled }, - set: { suggestionSettings.setPowerBasedModelSwitchingEnabled($0) } - ) - ) { - SettingsRowLabel( - title: "Switch Based on Power Source", - description: "Use a different engine or model on battery vs. while plugged in. " + - "For example, Apple Intelligence on battery to save power and a larger local " + - "model while charging.", - systemImage: "battery.100.bolt" - ) - } - .settingsItem(.powerBasedModelSwitching) - - if suggestionSettings.isPowerBasedModelSwitchingEnabled { - powerProfilePicker( - title: "On Battery", - systemImage: "battery.25", - selection: batteryProfileBinding - ) - .settingsItem(.batteryModel) - - powerProfilePicker( - title: "Plugged In", - systemImage: "powerplug", - selection: pluggedInProfileBinding - ) - .settingsItem(.pluggedInModel) - } - } - } - - /// One per-power-source profile picker. Lists Apple Intelligence (only when available) plus every - /// installed local model, tagged by `PowerProfile` so a single selection carries engine + model. - @ViewBuilder - private func powerProfilePicker( - title: String, - systemImage: String, - selection: Binding - ) -> some View { - Picker(selection: selection) { - if foundationModelAvailabilityService.isAvailable { - Text("Apple Intelligence").tag(PowerProfile.appleIntelligence) - } - - ForEach(runtimeModel.availableModels) { model in - Text(model.displayName).tag(PowerProfile.llama(filename: model.filename)) - } - - ForEach(endpointPowerModels, id: \.self) { modelName in - Text("Endpoint · \(modelName)").tag(PowerProfile.openAICompatible(modelName: modelName)) - } - } label: { - SettingsRowLabel( - title: title, - description: "Engine and model to use while on this power source.", - systemImage: systemImage - ) - } - .pickerStyle(.menu) - } - - private var batteryProfileBinding: Binding { - Binding( - get: { powerProfileForDisplay(suggestionSettings.batteryProfile) }, - set: { suggestionSettings.setBatteryProfile($0) } - ) - } - - private var pluggedInProfileBinding: Binding { - Binding( - get: { powerProfileForDisplay(suggestionSettings.pluggedInProfile) }, - set: { suggestionSettings.setPluggedInProfile($0) } - ) - } - - /// Falls a not-yet-chosen local profile back to the currently selected model so the picker shows - /// a concrete row instead of an empty selection, mirroring the primary model picker's fallback. - private func powerProfileForDisplay(_ profile: PowerProfile) -> PowerProfile { - if case .llama(let filename) = profile, filename.isEmpty { - return .llama(filename: runtimeModel.selectedModelFilename ?? "") - } - if case .openAICompatible(let modelName) = profile, modelName.isEmpty { - return .openAICompatible(modelName: suggestionSettings.openAICompatibleModelName) - } - - return profile - } - - private var endpointPowerModels: [String] { - var names = openAICompatibleConnectionModel.models.map(\.id) - let configured = suggestionSettings.openAICompatibleModelName - if !configured.isEmpty, !names.contains(configured) { - names.append(configured) - } - return names - } - - // MARK: - Apple Intelligence - - @ViewBuilder - private var appleIntelligenceSections: some View { - Section("Apple Intelligence") { - LabeledContent { - Text(foundationModelAvailabilityService.userVisibleMessage) - .foregroundStyle(foundationModelAvailabilityService.isAvailable ? .green : .orange) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } label: { - SettingsRowLabel( - title: "Availability", - description: "Whether this Mac can run Apple Intelligence. Requires a supported " + - "Apple Silicon Mac with Apple Intelligence turned on in System Settings.", - systemImage: "apple.logo" - ) - } - .settingsItem(.appleIntelligenceAvailability) - } - } - - // MARK: - Open Source - - @ViewBuilder - private var openSourceSections: some View { - Section("Runtime") { - LabeledContent { - Text(runtimeModel.state.summary) - .foregroundStyle(.secondary) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } label: { - SettingsRowLabel( - title: "Model Status", - description: "Whether the local model is loaded and ready to generate. " + - "Loading takes a few seconds the first time.", - systemImage: "info.circle" - ) - } - .settingsItem(.modelStatus) - } - - Section("Models") { - Text("Download a model or add your own below. Models are stored locally on your Mac.") - .font(.caption) - .foregroundStyle(.secondary) - - if runtimeModel.availableModels.isEmpty { - Text("No local GGUF models found. Download one below or add your own model file.") - .foregroundStyle(.secondary) - } else { - Picker(selection: selectedModelBinding) { - ForEach(runtimeModel.availableModels) { model in - Text(model.displayName).tag(model.filename) - } - } label: { - SettingsRowLabel( - title: "Selected Model", - description: suggestionSettings.isPowerBasedModelSwitchingEnabled - ? "Set automatically by power source. Use the On Battery / Plugged In " + - "pickers in the Power section, or turn off power-based switching." - : "Which downloaded model file generates suggestions. " + - "Larger models are slower but write better.", - systemImage: "shippingbox" - ) - } - // Redundant while power-based switching owns the active model: the Power section's - // per-source profile pickers are the source of truth, and any pick here would be - // reverted on the next power evaluation. - .disabled(suggestionSettings.isPowerBasedModelSwitchingEnabled) - .settingsItem(.selectedModel) - } - - DownloadableModelCatalogView( - modelDownloadManager: modelDownloadManager, - onRefreshModels: refreshModels - ) - .settingsItem(.downloadModels) - - HuggingFaceModelBrowserView( - searchService: huggingFaceSearchService, - modelDownloadManager: modelDownloadManager, - onRefreshModels: refreshModels - ) - .settingsItem(.huggingFaceBrowser) - } - - Section("Folder") { - LabeledContent { - VStack(alignment: .trailing, spacing: 8) { - Text(modelDownloadManager.modelsDirectoryPath) - .font(.callout.monospaced()) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - - HStack(spacing: 8) { - Button("Open Folder") { - modelDownloadManager.openModelsDirectory() - } - - Button("Refresh") { - refreshModels() - } - } - } - } label: { - SettingsRowLabel( - title: "Models Folder", - description: "Where downloaded model files are stored on this Mac.", - systemImage: "folder" - ) - } - .settingsItem(.modelsFolder) - - Toggle(isOn: $lmStudioSourceEnabled) { - SettingsRowLabel( - title: "Also Use LM Studio Models", - description: lmStudioModelsURL == nil - ? "Install LM Studio to load models from its library here." - : "Add models from your LM Studio library (~/.lmstudio/models) to the picker " + - "above. Downloads still save to Cotabby's own folder.", - systemImage: "square.stack.3d.up" - ) - } - .disabled(lmStudioModelsURL == nil) - .onChange(of: lmStudioSourceEnabled) { _, _ in - modelDownloadManager.refreshSearchDirectories() - refreshModels() - } - .settingsItem(.lmStudio) - } - - if !runtimeModel.availableModels.isEmpty { - Section("Installed") { - ForEach(runtimeModel.availableModels) { model in - installedModelRow(model) - } - } - } - } - - // MARK: - OpenAI-compatible endpoint - - /// Endpoint controls stay inside the unified engine pane. The connection model owns network - /// state, the settings model owns durable non-secret values, and the API key draft only lives - /// for this view's lifetime before an explicit Connect saves it to Keychain. - @ViewBuilder - private var openAICompatibleSections: some View { - Section("Connection") { - VStack(alignment: .leading, spacing: 10) { - SettingsRowLabel( - title: "Server URL", - description: "The OpenAI-compatible server address. Cotabby adds /v1 when the " + - "address has no path; Ollama uses http://127.0.0.1:11434 by default.", - systemImage: "network" - ) - - HStack(spacing: 8) { - TextField( - "", - text: endpointBaseURLBinding, - prompt: Text(OpenAICompatibleEndpointConfiguration.defaultBaseURLString) - ) - .labelsHidden() - .textFieldStyle(.roundedBorder) - .onSubmit(refreshEndpointModels) - - Button( - endpointConnectButtonTitle, - action: refreshEndpointModels - ) - .buttonStyle(.borderedProminent) - .disabled(openAICompatibleConnectionModel.state == .connecting) - } - - HStack(spacing: 8) { - endpointConnectionStatus - - Spacer(minLength: 8) - - Button("Use Ollama Default", systemImage: "arrow.counterclockwise") { - resetEndpointBaseURL() - } - .disabled( - suggestionSettings.openAICompatibleBaseURL - == OpenAICompatibleEndpointConfiguration.defaultBaseURLString - ) - } - } - .settingsItem(.endpointBaseURL) - - LabeledContent { - HStack(spacing: 8) { - SecureField("", text: $endpointAPIKeyDraft, prompt: Text("Paste API key")) - .labelsHidden() - .textFieldStyle(.roundedBorder) - .frame(minWidth: 200, idealWidth: 260, maxWidth: 300) - - if !endpointAPIKeyDraft.isEmpty { - Button("Clear") { - endpointAPIKeyDraft = "" - do { - try suggestionSettings.saveOpenAICompatibleAPIKey(nil) - endpointCredentialError = nil - } catch { - endpointCredentialError = error.localizedDescription - } - } - } - } - } label: { - SettingsRowLabel( - title: "API Key", - description: "Optional bearer token stored in Keychain. Ollama does not require one.", - systemImage: "key" - ) - } - .settingsItem(.endpointAPIKey) - - if let warning = endpointPrivacyWarning { - SettingsCalloutView(callout: SettingsPaneCallout(tone: .warning, message: warning)) - } - if let endpointCredentialError { - SettingsCalloutView( - callout: SettingsPaneCallout(tone: .warning, message: endpointCredentialError) - ) - } - } - - Section("Model") { - LabeledContent { - HStack(spacing: 6) { - TextField("", text: endpointModelBinding, prompt: Text("Enter model identifier")) - .labelsHidden() - .textFieldStyle(.roundedBorder) - .frame(minWidth: 220, idealWidth: 280, maxWidth: 320) - - Menu { - ForEach(openAICompatibleConnectionModel.models) { model in - Button(model.id) { - suggestionSettings.setOpenAICompatibleModelName(model.id) - } - } - } label: { - Image(systemName: "chevron.up.chevron.down") - .frame(width: 18, height: 18) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .disabled(openAICompatibleConnectionModel.models.isEmpty) - .help(endpointModelMenuHelp) - .accessibilityLabel("Choose a discovered model") - } - } label: { - SettingsRowLabel( - title: "Model", - description: "Choose a model returned by the server or enter its identifier manually.", - systemImage: "shippingbox" - ) - } - .settingsItem(.endpointModel) - - Picker(selection: endpointAPIModeBinding) { - Text("Chat Completions").tag(OpenAICompatibleAPIMode.chatCompletions) - Text("Text Completions").tag(OpenAICompatibleAPIMode.completions) - } label: { - SettingsRowLabel( - title: "API Format", - description: "Chat uses /v1/chat/completions (recommended). Text uses " + - "/v1/completions for base models that continue a raw prompt.", - systemImage: "arrow.left.arrow.right" - ) - } - .pickerStyle(.segmented) - .settingsItem(.endpointAPIMode) - } - } - - @ViewBuilder - private func installedModelRow(_ model: RuntimeModelOption) -> some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(model.displayName) - - if model.displayName != model.actualModelName { - Text(model.actualModelName) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer(minLength: 0) - - if model.filename == runtimeModel.selectedModelFilename { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.tint) - } else if modelDownloadManager.canDeleteModel(filename: model.filename) { - Button { - pendingDeletionModel = model - } label: { - Image(systemName: "trash") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - } - } - } - - // MARK: - Callout - - /// Surface the engine's failure mode at the top of the pane so it sits next to the controls - /// that fix it. Only the selected engine surfaces a warning; the inactive engine's status is - /// informational and doesn't warrant alarming the user. - private var callout: SettingsPaneCallout? { - switch suggestionSettings.selectedEngine { - case .appleIntelligence: - guard !foundationModelAvailabilityService.isAvailable else { return nil } - return SettingsPaneCallout( - tone: .warning, - message: foundationModelAvailabilityService.userVisibleMessage - ) - case .llamaOpenSource: - guard case .failed(let detail) = runtimeModel.state else { return nil } - return SettingsPaneCallout(tone: .warning, message: detail) - case .openAICompatible: - do { - let configuration = try suggestionSettings.openAICompatibleConfiguration - guard !configuration.modelName.isEmpty else { - return SettingsPaneCallout( - tone: .warning, - message: OpenAICompatibleEndpointError.emptyModelName.localizedDescription - ) - } - } catch { - return SettingsPaneCallout(tone: .warning, message: error.localizedDescription) - } - guard let detail = openAICompatibleConnectionModel.state.failureDetail else { return nil } - return SettingsPaneCallout(tone: .warning, message: detail) - } - } - - // MARK: - Bindings & actions - - private var selectedEngineBinding: Binding { - Binding( - get: { suggestionSettings.selectedEngine }, - set: { suggestionSettings.selectEngine($0) } - ) - } - - private var selectedModelBinding: Binding { - Binding( - get: { - runtimeModel.selectedModelFilename - ?? runtimeModel.availableModels.first?.filename - ?? "" - }, - set: { filename in - Task { await runtimeModel.selectModel(filename) } - } - ) - } - - private var endpointBaseURLBinding: Binding { - Binding( - get: { suggestionSettings.openAICompatibleBaseURL }, - set: { value in - suggestionSettings.setOpenAICompatibleBaseURL(value) - openAICompatibleConnectionModel.invalidate() - } - ) - } - - private var endpointModelBinding: Binding { - Binding( - get: { suggestionSettings.openAICompatibleModelName }, - set: { suggestionSettings.setOpenAICompatibleModelName($0) } - ) - } - - private var endpointAPIModeBinding: Binding { - Binding( - get: { suggestionSettings.openAICompatibleAPIMode }, - set: { suggestionSettings.setOpenAICompatibleAPIMode($0) } - ) - } - - private var endpointPrivacyWarning: String? { - (try? suggestionSettings.openAICompatibleConfiguration)?.privacyWarning - } - - /// Compact feedback stays beside the connection action instead of repeating the URL in a second - /// card. This view has no independent lifetime; the endpoint connection model drives every state. - private var endpointConnectionStatus: some View { - HStack(spacing: 6) { - Group { - if openAICompatibleConnectionModel.state == .connecting { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: endpointConnectionSymbol) - .foregroundStyle(endpointConnectionColor) - } - } - .frame(width: 14) - - Text(openAICompatibleConnectionModel.state.summary) - .font(.caption.weight(.medium)) - .foregroundStyle(endpointConnectionColor) - .lineLimit(2) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("Server status") - .accessibilityValue("\(openAICompatibleConnectionModel.state.summary), \(endpointDisplayURL)") - .settingsItem(.endpointStatus) - } - - private var endpointConnectButtonTitle: String { - if case .ready = openAICompatibleConnectionModel.state { - return "Refresh" - } - return "Connect" - } - - private var endpointConnectionSymbol: String { - switch openAICompatibleConnectionModel.state { - case .idle: return "circle.dashed" - case .connecting: return "circle.dashed" - case .ready: return "checkmark.circle.fill" - case .failed: return "exclamationmark.triangle.fill" - } - } - - private var endpointConnectionColor: Color { - switch openAICompatibleConnectionModel.state { - case .idle, .connecting: return .secondary - case .ready: return .green - case .failed: return .orange - } - } - - private var endpointDisplayURL: String { - if let configuration = try? suggestionSettings.openAICompatibleConfiguration { - return configuration.baseURL.absoluteString - } - let enteredURL = suggestionSettings.openAICompatibleBaseURL - .trimmingCharacters(in: .whitespacesAndNewlines) - return enteredURL.isEmpty ? "No endpoint configured" : enteredURL - } - - private var endpointModelMenuHelp: String { - if openAICompatibleConnectionModel.models.isEmpty { - return "Connect to load models, or enter an identifier manually." - } - return "Choose from \(openAICompatibleConnectionModel.models.count) discovered models." - } - - private var pendingDeletionAlertBinding: Binding { - Binding( - get: { pendingDeletionModel != nil }, - set: { isPresented in - if !isPresented { - pendingDeletionModel = nil - } - } - ) - } - - private func deleteModel(_ model: RuntimeModelOption) { - modelDownloadManager.deleteModel(filename: model.filename) - runtimeModel.refreshAvailableModels() - pendingDeletionModel = nil - } - - private func refreshModels() { - modelDownloadManager.refreshModelStates() - runtimeModel.refreshAvailableModels() - } - - /// Reset is intentionally local to the address. The selected model and Keychain credential - /// remain untouched because they may still be valid for the default Ollama server. Invalidating - /// discovery makes the status honest until the user explicitly connects again. - private func resetEndpointBaseURL() { - suggestionSettings.setOpenAICompatibleBaseURL( - OpenAICompatibleEndpointConfiguration.defaultBaseURLString - ) - openAICompatibleConnectionModel.invalidate() - } - - private func loadEndpointAPIKey() { - do { - endpointAPIKeyDraft = try suggestionSettings.openAICompatibleAPIKey() ?? "" - endpointCredentialError = nil - } catch { - endpointCredentialError = error.localizedDescription - } - } - - private func refreshEndpointModels() { - Task { - do { - try suggestionSettings.saveOpenAICompatibleAPIKey(endpointAPIKeyDraft) - endpointCredentialError = nil - let configuration = try suggestionSettings.openAICompatibleConfiguration - await openAICompatibleConnectionModel.refresh( - configuration: configuration, - apiKey: endpointAPIKeyDraft - ) - if let preferredModel = OpenAICompatibleModelSelectionResolver.preferredSelection( - currentSelection: suggestionSettings.openAICompatibleModelName, - discoveredModels: openAICompatibleConnectionModel.models - ), preferredModel != suggestionSettings.openAICompatibleModelName { - suggestionSettings.setOpenAICompatibleModelName(preferredModel) - } - } catch { - endpointCredentialError = error.localizedDescription - openAICompatibleConnectionModel.setFailure(error.localizedDescription) - } - } - } } diff --git a/CotabbyTests/Support/Suggestion/FocusSnapshotExternalApplicationIdentityTests.swift b/CotabbyTests/Support/Suggestion/FocusSnapshotExternalApplicationIdentityTests.swift new file mode 100644 index 00000000..57ec10da --- /dev/null +++ b/CotabbyTests/Support/Suggestion/FocusSnapshotExternalApplicationIdentityTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import Cotabby + +/// Tests for the app identity that menu-bar controls target. +/// +/// This is deliberately a pure model test instead of a SwiftUI test. The behavior we care about is +/// not pixels; it is the invariant that Cotabby's own transient focus does not become the app rule +/// target after the user opens the menu bar. +final class FocusSnapshotExternalApplicationIdentityTests: XCTestCase { + func test_externalApplicationIdentity_returnsNonCotabbyApplication() { + let snapshot = FocusSnapshot( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + capability: .supported, + context: nil, + inspection: nil + ) + + XCTAssertEqual( + snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby"), + FocusedApplicationIdentity( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome" + ) + ) + } + + func test_externalApplicationIdentity_ignoresCotabbyApplication() { + let snapshot = FocusSnapshot( + applicationName: "Cotabby", + bundleIdentifier: "com.jacobfu.tabby", + capability: .blocked("Cotabby is focused."), + context: nil, + inspection: nil + ) + + XCTAssertNil( + snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby") + ) + } + + func test_externalApplicationIdentity_returnsNilWhenBundleIdentifierIsMissing() { + let snapshot = FocusSnapshot( + applicationName: "Unknown", + bundleIdentifier: nil, + capability: .unsupported("No active application."), + context: nil, + inspection: nil + ) + + XCTAssertNil( + snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby") + ) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/Support/Suggestion/SuggestionAvailabilityEvaluatorTests.swift index 24461f17..4bf7d114 100644 --- a/CotabbyTests/Support/Suggestion/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/Support/Suggestion/SuggestionAvailabilityEvaluatorTests.swift @@ -404,380 +404,3 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { ) } } - -/// Tests for the app identity that menu-bar controls target. -/// -/// This is deliberately a pure model test instead of a SwiftUI test. The behavior we care about is -/// not pixels; it is the invariant that Cotabby's own transient focus does not become the app rule -/// target after the user opens the menu bar. -final class FocusSnapshotExternalApplicationIdentityTests: XCTestCase { - func test_externalApplicationIdentity_returnsNonCotabbyApplication() { - let snapshot = FocusSnapshot( - applicationName: "Google Chrome", - bundleIdentifier: "com.google.Chrome", - capability: .supported, - context: nil, - inspection: nil - ) - - XCTAssertEqual( - snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby"), - FocusedApplicationIdentity( - applicationName: "Google Chrome", - bundleIdentifier: "com.google.Chrome" - ) - ) - } - - func test_externalApplicationIdentity_ignoresCotabbyApplication() { - let snapshot = FocusSnapshot( - applicationName: "Cotabby", - bundleIdentifier: "com.jacobfu.tabby", - capability: .blocked("Cotabby is focused."), - context: nil, - inspection: nil - ) - - XCTAssertNil( - snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby") - ) - } - - func test_externalApplicationIdentity_returnsNilWhenBundleIdentifierIsMissing() { - let snapshot = FocusSnapshot( - applicationName: "Unknown", - bundleIdentifier: nil, - capability: .unsupported("No active application."), - context: nil, - inspection: nil - ) - - XCTAssertNil( - snapshot.externalApplicationIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby") - ) - } -} - -/// Tests for the durable disabled-app blocklist. -/// -/// These live beside the evaluator tests because the two pieces form one contract: settings own -/// persistence, while the evaluator consumes the snapshot produced from those settings. -final class SuggestionSettingsModelDisabledAppsTests: XCTestCase { - /// Hosted macOS tests are currently crashing while deallocating short-lived - /// `SuggestionSettingsModel` instances. Retaining the models for the full process lifetime - /// quarantines that runtime issue so these tests can keep asserting the persistence contract. - private static var retainedModels: [SuggestionSettingsModel] = [] - - /// Keep the suite object and its name together so teardown clears the exact domain each test - /// created. This avoids reaching back through `UserDefaults.standard`, which is a broader - /// global API surface than these tests actually need. - private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] - - override func tearDown() { - for suite in userDefaultsSuites { - suite.userDefaults.removePersistentDomain(forName: suite.suiteName) - } - userDefaultsSuites.removeAll() - super.tearDown() - } - - func test_disabledAppRules_surviveModelRecreation() { - runOnMainActor { - let userDefaults = makeUserDefaults() - let model = makeModel(userDefaults: userDefaults) - - model.disableApplication( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari" - ) - - let reloadedModel = makeModel(userDefaults: userDefaults) - - XCTAssertEqual( - reloadedModel.disabledAppRules, - [ - DisabledApplicationRule( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari" - ) - ] - ) - } - } - - func test_disableApplication_reusesBundleIdentifierInsteadOfDuplicating() { - runOnMainActor { - let model = makeModel() - - model.disableApplication( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari" - ) - model.disableApplication( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari Technology Preview" - ) - - XCTAssertEqual(model.disabledAppRules.count, 1) - XCTAssertEqual( - model.disabledAppRules.first?.displayName, - "Safari Technology Preview" - ) - } - } - - func test_removeDisabledApplication_deletesOnlyMatchingBundleIdentifier() { - runOnMainActor { - let model = makeModel() - - model.disableApplication( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari" - ) - model.disableApplication( - bundleIdentifier: "com.tinyspeck.slackmacgap", - displayName: "Slack" - ) - model.removeDisabledApplication(bundleIdentifier: "com.apple.Safari") - - XCTAssertFalse(model.isApplicationDisabled(bundleIdentifier: "com.apple.Safari")) - XCTAssertTrue( - model.isApplicationDisabled(bundleIdentifier: "com.tinyspeck.slackmacgap") - ) - XCTAssertEqual( - model.disabledAppRules.map(\.bundleIdentifier), - ["com.tinyspeck.slackmacgap"] - ) - } - } - - func test_snapshotPublisher_emitsWhenDisabledAppRulesChange() { - let expectation = expectation(description: "snapshot emits after app rule changes") - var cancellables = Set() - - runOnMainActor { - let model = makeModel() - - model.snapshotPublisher - .dropFirst() - .sink { snapshot in - XCTAssertTrue(snapshot.disabledAppBundleIdentifiers.contains("com.apple.Safari")) - expectation.fulfill() - } - .store(in: &cancellables) - - model.disableApplication( - bundleIdentifier: "com.apple.Safari", - displayName: "Safari" - ) - } - - wait(for: [expectation], timeout: 1.0) - _ = cancellables - } - - func test_clipboardContextEnabled_defaultsToFalseAndPersists() { - runOnMainActor { - let userDefaults = makeUserDefaults() - let model = makeModel(userDefaults: userDefaults) - - XCTAssertFalse(model.isClipboardContextEnabled) - XCTAssertFalse(model.snapshot.isClipboardContextEnabled) - - model.setClipboardContextEnabled(true) - let reloadedModel = makeModel(userDefaults: userDefaults) - - XCTAssertTrue(reloadedModel.isClipboardContextEnabled) - XCTAssertTrue(reloadedModel.snapshot.isClipboardContextEnabled) - } - } - - func test_snapshotPublisher_emitsWhenClipboardContextSettingChanges() { - let expectation = expectation(description: "snapshot emits after clipboard setting changes") - var cancellables = Set() - - runOnMainActor { - let model = makeModel() - - model.snapshotPublisher - .dropFirst() - .sink { snapshot in - XCTAssertTrue(snapshot.isClipboardContextEnabled) - expectation.fulfill() - } - .store(in: &cancellables) - - model.setClipboardContextEnabled(true) - } - - wait(for: [expectation], timeout: 1.0) - _ = cancellables - } - - func test_fastMode_defaultsToFalseAndPersists() { - runOnMainActor { - let userDefaults = makeUserDefaults() - let model = makeModel(userDefaults: userDefaults) - - XCTAssertFalse(model.isFastModeEnabled) - XCTAssertFalse(model.snapshot.isFastModeEnabled) - - model.setFastModeEnabled(true) - let reloadedModel = makeModel(userDefaults: userDefaults) - - XCTAssertTrue(reloadedModel.isFastModeEnabled) - XCTAssertTrue(reloadedModel.snapshot.isFastModeEnabled) - } - } - - func test_snapshotPublisher_emitsWhenFastModeSettingChanges() { - let expectation = expectation(description: "snapshot emits after fast mode setting changes") - var cancellables = Set() - - runOnMainActor { - let model = makeModel() - - model.snapshotPublisher - .dropFirst() - .sink { snapshot in - XCTAssertTrue(snapshot.isFastModeEnabled) - expectation.fulfill() - } - .store(in: &cancellables) - - model.setFastModeEnabled(true) - } - - wait(for: [expectation], timeout: 1.0) - _ = cancellables - } - - func test_mirrorPreference_defaultsToAutoAndPersists() { - runOnMainActor { - let userDefaults = makeUserDefaults() - let model = makeModel(userDefaults: userDefaults) - - XCTAssertEqual(model.mirrorPreference, .auto) - XCTAssertEqual(model.snapshot.mirrorPreference, .auto) - - model.setMirrorPreference(.alwaysMirror) - let reloadedModel = makeModel(userDefaults: userDefaults) - - XCTAssertEqual(reloadedModel.mirrorPreference, .alwaysMirror) - XCTAssertEqual(reloadedModel.snapshot.mirrorPreference, .alwaysMirror) - } - } - - func test_snapshotPublisher_emitsWhenMirrorPreferenceChanges() { - let expectation = expectation(description: "snapshot emits after mirror preference changes") - var cancellables = Set() - - runOnMainActor { - let model = makeModel() - - model.snapshotPublisher - .dropFirst() - .sink { snapshot in - XCTAssertEqual(snapshot.mirrorPreference, .alwaysInline) - expectation.fulfill() - } - .store(in: &cancellables) - - model.setMirrorPreference(.alwaysInline) - } - - wait(for: [expectation], timeout: 1.0) - _ = cancellables - } - - func test_acceptanceHint_defaultsToOnAndShowsWordAcceptLabel() { - runOnMainActor { - let model = makeModel() - - XCTAssertTrue(model.showAcceptanceHint) - XCTAssertEqual(model.acceptanceHintLabel, SuggestionSettingsModel.defaultAcceptanceKeyLabel) - } - } - - func test_showAcceptanceHint_persistsAcrossModelRecreation() { - runOnMainActor { - let userDefaults = makeUserDefaults() - let model = makeModel(userDefaults: userDefaults) - - model.setShowAcceptanceHint(false) - let reloadedModel = makeModel(userDefaults: userDefaults) - - XCTAssertFalse(reloadedModel.showAcceptanceHint) - XCTAssertNil(reloadedModel.acceptanceHintLabel, "Disabled hint should resolve to no label") - } - } - - func test_acceptanceHintLabel_tracksRebindAndFallsBackWhenWordAcceptCleared() { - runOnMainActor { - let model = makeModel() - - model.setAcceptanceKey(keyCode: 49, modifiers: [], label: "Space") - XCTAssertEqual(model.acceptanceHintLabel, "Space", "Hint should follow the rebound word-accept key") - - // Clearing word-accept should fall back to the still-bound full-accept key. - model.clearAcceptanceKey() - XCTAssertEqual(model.acceptanceHintLabel, model.fullAcceptanceKeyLabel) - - // With no accept key bound at all, there is nothing to teach. - model.clearFullAcceptanceKey() - XCTAssertNil(model.acceptanceHintLabel) - } - } - - func test_emojiPickerAcceptKeyLabel_ignoresGhostHintToggleAndRequiresWordAccept() { - runOnMainActor { - let model = makeModel() - - model.setShowAcceptanceHint(false) - XCTAssertEqual(model.emojiPickerAcceptKeyLabel, SuggestionSettingsModel.defaultAcceptanceKeyLabel) - - model.clearAcceptanceKey() - XCTAssertNil(model.emojiPickerAcceptKeyLabel) - } - } - - @MainActor - private func makeModel( - userDefaults: UserDefaults? = nil - ) -> SuggestionSettingsModel { - let model = SuggestionSettingsModel( - configuration: .standard, - userDefaults: userDefaults ?? makeUserDefaults() - ) - Self.retainedModels.append(model) - return model - } - - private func makeUserDefaults() -> UserDefaults { - let suiteName = "SuggestionSettingsModelDisabledAppsTests-\(UUID().uuidString)" - guard let userDefaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Expected an isolated UserDefaults suite") - return .standard - } - - userDefaults.removePersistentDomain(forName: suiteName) - userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) - return userDefaults - } - - /// `MainActor.assumeIsolated` lets the compiler treat the closure as main-actor bound once we - /// have synchronously hopped to the main thread. This keeps the tests deterministic without - /// wrapping each case in a Swift concurrency task, which is the teardown path that was - /// crashing during hosted test execution. - private func runOnMainActor( - _ body: @MainActor () throws -> Result - ) rethrows -> Result { - if Thread.isMainThread { - return try MainActor.assumeIsolated(body) - } - - return try DispatchQueue.main.sync { - try MainActor.assumeIsolated(body) - } - } -} diff --git a/CotabbyTests/Support/Suggestion/SuggestionInsertionChunkTests.swift b/CotabbyTests/Support/Suggestion/SuggestionInsertionChunkTests.swift new file mode 100644 index 00000000..9163b097 --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionInsertionChunkTests.swift @@ -0,0 +1,273 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionInsertionChunkTests: XCTestCase { + func test_insertionChunk_dropsLeadingSpaceWhenPrecedingTextAlreadyEndsInWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are "), + "you" + ) + } + + func test_insertionChunk_keepsLeadingSpaceWhenPrecedingTextHasNoTrailingWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are"), + " you" + ) + } + + func test_insertionChunk_collapsesAWholeLeadingRunAgainstFieldWhitespace() { + // The reported "bunch of spaces" case: a field that already ends in a space plus a chunk + // carrying its own leading space(s) must not stack them. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are "), + "you" + ) + } + + func test_insertionChunk_leavesChunkUntouchedWhenItHasNoLeadingWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "you", precedingText: "How are "), + "you" + ) + } + + func test_insertionChunk_treatsTabAsBoundaryWhitespaceButNotNewline() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are\t"), + "you" + ) + // Newlines are not horizontal whitespace, so a leading space after a line break is kept. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "line\n"), + " you" + ) + } + + func test_insertionChunk_preservesInterWordSpaceMidSuggestion() { + // After "you" was already inserted, the field ends in a word, so the next chunk's space + // is the real boundary and must survive. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " are", precedingText: "How are you"), + " are" + ) + } + + func test_insertionChunk_returnsChunkUnchangedForEmptyPrecedingText() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: ""), + " you" + ) + } + + func test_insertionChunk_continuesPartialWordWhenModelOmitsLeadingSpace() { + // Regression for issue #621 ("after" -> "afternoon" committing as "after noon"): the caret + // sits at the end of a partial word and the model continues it with no leading space. We type + // the continuation verbatim so it glues into one word instead of synthesizing a boundary. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "noon", precedingText: "after"), + "noon" + ) + } + + func test_insertionChunk_trustsModelAndDoesNotSynthesizeBoundary() { + // Trust-the-model: when the chunk has no leading space and the field ends in a word + // character, we no longer insert one. A genuine new word arrives with the model's own leading + // space (see `keepsLeadingSpaceWhenPrecedingTextHasNoTrailingWhitespace`); when the model + // omits it the words glue, which is exactly what the ghost text showed, so accept stays + // WYSIWYG. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "Hello"), + "World" + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "world", precedingText: "the"), + "world" + ) + } + + func test_insertionChunk_doesNotSynthesizeBoundaryAcrossDigitWordBoundary() { + // Same trust-the-model contract across a digit/letter boundary: no synthesized separator, so + // the model decides whether "123" continues into "abc" or stands apart. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "abc", precedingText: "123"), + "abc" + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "1st", precedingText: "Hello"), + "1st" + ) + } + + func test_insertionChunk_doesNotAddBoundarySpaceWhenChunkStartsWithPunctuation() { + // Punctuation-leading chunks ("." closes a sentence, "'s" is a possessive, "," is a list + // continuation) intentionally attach to the prior word without a separator. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: ".", precedingText: "Hello"), + "." + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "'s", precedingText: "John"), + "'s" + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: ", more", precedingText: "first"), + ", more" + ) + } + + func test_insertionChunk_doesNotAddBoundarySpaceAfterPunctuation() { + // Opening punctuation in the prefix means the chunk should hug it, not be separated from it. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "Hello ("), + "World" + ) + } + + func test_insertionChunk_doesNotAddBoundarySpaceAfterNewline() { + // A line break is a hard boundary on its own; we should not synthesize an indent space here. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "line\n"), + "World" + ) + } + + func test_insertionChunk_doesNotAddBoundarySpaceWhenPrecedingTextIsEmpty() { + // At the very start of an empty field there is no last word to glue onto. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: ""), + "World" + ) + } + + func test_insertionChunk_dropsLeadingHorizontalWhitespaceButNotLeadingNewline() { + // The drop predicate must mirror the guard's horizontal-whitespace definition, so a chunk + // whose first character is a newline survives even when the field ends in a space — keeping + // the structural line break the suggestion was authored with. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "\nnext", precedingText: "first "), + "\nnext" + ) + } + + func test_insertionChunkAppendingTrailingSpace_appendsAfterFinishedWord() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello"), + "hello " + ) + } + + func test_insertionChunkAppendingTrailingSpace_appendsAfterTrailingDigit() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("section 12"), + "section 12 " + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsWhenEndingInPunctuation() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("done."), + "done." + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("really?!"), + "really?!" + ) + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("(yes)"), + "(yes)" + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsWhenAlreadyEndingInWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello "), + "hello " + ) + } + + func test_insertionChunkAppendingTrailingSpace_skipsForSpacelessScript() { + // CJK glyphs are letters, but their scripts never separate words with spaces, so a trailing + // space would be wrong. The space-less-script guard suppresses it. + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("資料"), + "資料" + ) + } + + func test_insertionChunkAppendingTrailingSpace_leavesEmptyChunkUntouched() { + XCTAssertEqual( + SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(""), + "" + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_takesFollowingSpaceAfterWord() { + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world how are you"), + "world " + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_keepsLeadingWhitespaceAndTakesFollowingSpace() { + // nextAcceptanceChunk returns leading whitespace with the token, so the extension must keep it + // and still consume the space that follows the word. + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace(" world", remainingText: " world how"), + " world " + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_takesWholeHorizontalRun() { + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world\t how"), + "world\t " + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_noFollowingWhitespaceLeavesChunkUntouched() { + // End of the suggestion: nothing to consume here — the exhaustion-time append covers it. + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world"), + "world" + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_doesNotCrossNewline() { + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("line", remainingText: "line\nnext"), + "line" + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_doesNotConsumeBeforePunctuation() { + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world, how"), + "world" + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_skipsWhenChunkEndsInPunctuation() { + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("done.", remainingText: "done. next"), + "done." + ) + } + + func test_acceptanceChunkConsumingTrailingSpace_skipsForSpacelessScript() { + // CJK scripts do not separate words with spaces, so even a stray following space is not taken. + XCTAssertEqual( + SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("資料", remainingText: "資料 です"), + "資料" + ) + } + + func test_acceptedWordCount_countsOnlyTokensWithAlphanumerics() { + let count = SuggestionSessionReconciler.acceptedWordCount( + in: "hello, !!! world 123 --" + ) + + XCTAssertEqual(count, 3) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionOverlayAcceptanceTests.swift b/CotabbyTests/Support/Suggestion/SuggestionOverlayAcceptanceTests.swift new file mode 100644 index 00000000..ed0add9d --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionOverlayAcceptanceTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionOverlayAcceptanceTests: XCTestCase { + func test_overlayAllowsAcceptance_trueWhenOverlayHidden() { + XCTAssertTrue( + SuggestionSessionReconciler.overlayAllowsAcceptance( + of: " world", + overlayState: .hidden(reason: "waiting for AX") + ) + ) + } + + func test_overlayAllowsAcceptance_trueOnlyWhenVisibleTextMatches() { + let caretRect = CGRect(x: 10, y: 20, width: 2, height: 18) + + XCTAssertTrue( + SuggestionSessionReconciler.overlayAllowsAcceptance( + of: " world", + overlayState: .visible( + text: " world", + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: caretRect), + mode: .inline + ) + ) + ) + XCTAssertFalse( + SuggestionSessionReconciler.overlayAllowsAcceptance( + of: " world", + overlayState: .visible( + text: " there", + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: caretRect), + mode: .inline + ) + ) + ) + } + + func test_overlayHideReason_mapsSemanticInputEventsToUserVisibleReasons() { + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: .textMutation) + ), + "Overlay hidden because typing invalidated the current suggestion." + ) + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: .navigation) + ), + "Overlay hidden because caret navigation invalidated the current suggestion." + ) + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: .dismissal) + ), + "Overlay hidden because a dismissal key was pressed." + ) + } + + func test_overlayHideReason_acceptanceAndOtherEventsUseTheGenericReason() { + // Acceptance-driven hides are expected behavior, not invalidation, so they get the plain + // message; shortcut mutations read as typing. + for kind in [CapturedInputEvent.Kind.acceptance, .fullAcceptance, .other] { + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: kind) + ), + "Overlay hidden." + ) + } + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: .shortcutMutation) + ), + "Overlay hidden because typing invalidated the current suggestion." + ) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionPhraseAcceptanceTests.swift b/CotabbyTests/Support/Suggestion/SuggestionPhraseAcceptanceTests.swift new file mode 100644 index 00000000..51c6354a --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionPhraseAcceptanceTests.swift @@ -0,0 +1,283 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionPhraseAcceptanceTests: XCTestCase { + func test_nextAcceptancePhrase_returnsEmptyForEmptyTail() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: ""), "") + } + + func test_nextAcceptancePhrase_returnsWholeTailWhenNoTerminatorPresent() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world again"), + "hello world again" + ) + } + + func test_nextAcceptancePhrase_stopsAtFirstPeriod() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world. foo bar."), + "hello world." + ) + } + + func test_nextAcceptancePhrase_stopsAtFirstQuestionMark() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "how are you? fine."), + "how are you?" + ) + } + + func test_nextAcceptancePhrase_stopsAtFirstExclamation() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "stop! go back"), + "stop!" + ) + } + + func test_nextAcceptancePhrase_stopsAtNewlineBetweenTokens() { + // Composition over the word chunker would otherwise carry the newline as leading whitespace + // into the next iteration's accumulated chunk; the in-chunk newline scan must catch it. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello\nworld"), + "hello\n" + ) + } + + func test_nextAcceptancePhrase_stopsAtLeadingNewline() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\nworld"), + "\n" + ) + } + + func test_nextAcceptancePhrase_stopsAtFirstOfMultipleNewlines() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\n\nbody"), + "\n" + ) + } + + func test_nextAcceptancePhrase_includesLeadingWhitespaceUpToTerminator() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: " hello. world."), + " hello." + ) + } + + func test_nextAcceptancePhrase_preservesInteriorPunctuationWithinTokens() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "don't go. yes"), + "don't go." + ) + } + + // MARK: - CJK phrase boundaries + + /// The reported case: a space-less Japanese sentence must not arrive as one giant Tab. The + /// ideographic comma is a clause boundary, so phrase accepts advance clause by clause. + func test_nextAcceptancePhrase_stopsAtIdeographicComma() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "理解し、その内容を自分の言葉で表現する。"), + "理解し、" + ) + } + + func test_nextAcceptancePhrase_stopsAtIdeographicFullStop() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "その内容を自分の言葉で表現する。次の文"), + "その内容を自分の言葉で表現する。" + ) + } + + func test_nextAcceptancePhrase_stopsAtFullwidthExclamationAndQuestion() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "すごい!次へ"), "すごい!") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "いいですか?はい"), "いいですか?") + } + + /// The closer-walk must work for CJK quotes too: the accumulated tail is `」`, and the + /// terminator underneath is the ideographic full stop. + func test_nextAcceptancePhrase_walksPastCJKClosingQuote() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "終わり。」次の文"), + "終わり。」" + ) + } + + /// ASCII commas must stay non-boundaries so English phrase cadence is unchanged by the CJK rules. + func test_nextAcceptancePhrase_doesNotStopAtAsciiComma() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello, world. next"), + "hello, world." + ) + } + + // MARK: - CJK punctuation binding in word chunks + + /// Trailing CJK punctuation binds to the word it follows, so one Tab accepts the word and its + /// comma as a unit instead of stranding the comma to lead the next chunk. + func test_nextAcceptanceChunk_bindsTrailingIdeographicCommaToWord() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "資料、内容"), "資料、") + } + + /// A punctuation-led tail peels the punctuation run as its own chunk. Before this rule the token + /// skipped ICU segmentation (punctuation does not begin a space-less-script word) and the accept + /// swallowed everything up to the next whitespace in one chunk. + func test_nextAcceptanceChunk_peelsLeadingCJKPunctuationRunInsteadOfSwallowingTheTail() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "、理解し、その内容"), "、") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "。」次の文"), "。」") + } + + /// CJK opening brackets are peeled too: `「` leads the word it quotes, so it neither begins a + /// space-less-script word nor binds to the preceding one, and without the peel a quoted run in + /// flat text would be swallowed whole (`「分かった」と言った` after `は` in one Tab). + func test_nextAcceptanceChunk_peelsLeadingCJKOpeningBracket() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "「分かった」と言った"), "「") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "【内容】次"), "【") + } + + /// A mixed close-then-open run (`。」「`) peels as one punctuation chunk, so back-to-back quotes + /// never strand the walker. + func test_nextAcceptanceChunk_peelsMixedCloserOpenerRunAsOneChunk() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "。」「次の文"), "。」「") + } + + /// The katakana middle dot lives in the kana block, so it enters the ICU branch, but a run of + /// middle dots contains no segmentable word. The chunker must fall back to the whole + /// whitespace-bounded token rather than producing an empty chunk and stalling. + func test_nextAcceptanceChunk_kanaPunctuationRunWithoutWordsAcceptsWholeToken() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・ あと"), "・・・") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・"), "・・・") + } + + /// The trailing binding must stop before an opening bracket: the closer and full stop belong to + /// the word, but the next quote's opener belongs to the next word. + func test_nextAcceptanceChunk_trailingBindingStopsBeforeOpeningBracket() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "内容。「次"), "内容。") + } + + /// Halfwidth kana punctuation (legacy SJIS contexts) behaves like its fullwidth counterparts: + /// the halfwidth comma is a clause boundary and the halfwidth corner bracket binds and walks. + func test_halfwidthKanaPunctuation_matchesFullwidthBehavior() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "資料を読み、次へ"), "資料を読み、") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "終わり。」次の文"), "終わり。」") + } + + /// ASCII brackets and quotes must keep their existing whole-token behavior: the CJK opener peel + /// is scoped to CJK codepoints, so space-delimited scripts stay byte-for-byte unchanged. + func test_nextAcceptanceChunk_asciiBracketsUnchangedByOpenerPeel() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "(hello) world"), "(hello)") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "\"quote\" next"), "\"quote\"") + } + + // MARK: - CJK punctuation under trailing-punctuation policy + + /// With trailing-punctuation auto-accept off, the CJK binding is intentionally re-peeled: the word + /// accepts on its own and the clause comma waits for the next Tab, exactly how ASCII trailing + /// punctuation behaves under the same setting. The binding is a no-op in this path by design. + func test_nextAcceptanceChunk_autoAcceptOff_trimsBoundCJKCommaBackOffTheWord() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "資料、内容", autoAcceptTrailingPunctuation: false), + "資料" + ) + } + + /// A punctuation-led peel must stay non-empty with auto-accept off. Trimming would otherwise strip + /// the whole chunk and stall the phrase walker, but `wordEndTrimmingTrailingPunctuation` returns + /// nil for a punctuation-only token, so the comma survives as its own chunk. + func test_nextAcceptanceChunk_autoAcceptOff_keepsPunctuationOnlyPeelNonEmpty() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "、内容", autoAcceptTrailingPunctuation: false), + "、" + ) + } + + /// The flag never changes phrase output: with auto-accept off the word and comma arrive as separate + /// chunks, but they accumulate to the same clause the flag-on path returns in one binding. + func test_nextAcceptancePhrase_autoAcceptOff_stillStopsAtIdeographicComma() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase( + from: "理解し、その内容を自分の言葉で表現する。", + autoAcceptTrailingPunctuation: false + ), + "理解し、" + ) + } + + func test_nextAcceptancePhrase_walksPastDottedInitialsToRealSentenceEnd() { + // "U.S.A." is a run of single-letter initials, so its interior periods are not sentence + // ends. SentenceBoundaryClassifier keeps phrase acceptance going until the real terminator + // after "great" (see SentenceBoundaryClassifierTests for the period-disambiguation rules). + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "U.S.A. is great."), + "U.S.A. is great." + ) + } + + func test_nextAcceptancePhrase_isInvariantToAutoAcceptTrailingPunctuationFlag() { + let tail = "you? Yes." + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: tail, autoAcceptTrailingPunctuation: true), + "you?" + ) + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: tail, autoAcceptTrailingPunctuation: false), + "you?" + ) + } + + func test_nextAcceptancePhrase_stopsAtNewlineEvenWhenPunctuationPrecedes() { + // The newline must win over a sentence-terminator on the same line so paragraph breaks are + // never accidentally skipped past. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world\nmore"), + "hello world\n" + ) + } + + func test_nextAcceptancePhrase_stopsAtSentenceEndInsideStraightQuotes() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"done.\" Next sentence."), + "\"done.\"" + ) + } + + func test_nextAcceptancePhrase_stopsAtSentenceEndInsideCurlyQuotes() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\u{201C}done.\u{201D} Next."), + "\u{201C}done.\u{201D}" + ) + } + + func test_nextAcceptancePhrase_stopsAtSentenceEndInsideParentheses() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "(yes!) keep going"), + "(yes!)" + ) + } + + func test_nextAcceptancePhrase_walksPastMultipleClosingPunctuation() { + // Nested closers — quote inside parens, sentence ends inside both. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "(\"done.\") next"), + "(\"done.\")" + ) + } + + func test_nextAcceptancePhrase_doesNotBreakOnBareClosingQuote() { + // Closing quote with no preceding sentence terminator is not a phrase boundary. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"hi\" there"), + "\"hi\" there" + ) + } + + func test_nextAcceptancePhrase_chunkOfOnlyClosingPunctuationIsNotABoundary() { + // The closer walk-back can consume the entire accumulated chunk; with no character left + // underneath there is no terminator, so the phrase must keep accumulating. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"\" hello"), + "\"\" hello" + ) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionSessionReconcilerTests.swift b/CotabbyTests/Support/Suggestion/SuggestionSessionReconcilerTests.swift deleted file mode 100644 index acf21c24..00000000 --- a/CotabbyTests/Support/Suggestion/SuggestionSessionReconcilerTests.swift +++ /dev/null @@ -1,1177 +0,0 @@ -import CoreGraphics -import XCTest -@testable import Cotabby - -/// Tests for the pure state-machine rules behind partial acceptance. -/// -/// This is the highest-risk autocomplete logic because it decides whether a live editor change is -/// still consistent with the active ghost-text tail or whether Cotabby must invalidate the session. -final class SuggestionSessionReconcilerTests: XCTestCase { - func test_advanceIfTypedCharactersMatch_advancesMatchingDirectText() { - let session = CotabbyTestFixtures.activeSession(fullText: " world again") - - let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( - " world", - session: session - ) - - XCTAssertEqual(advanced?.acceptedText, " world") - XCTAssertEqual(advanced?.remainingText, " again") - } - - func test_advanceIfTypedCharactersMatch_returnsNilForDivergentText() { - let session = CotabbyTestFixtures.activeSession(fullText: " world again") - - let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( - " there", - session: session - ) - - XCTAssertNil(advanced) - } - - func test_advanceIfTypedCharactersMatch_returnsNilForControlCharacters() { - let session = CotabbyTestFixtures.activeSession(fullText: " world again") - - let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( - "\n", - session: session - ) - - XCTAssertNil(advanced) - } - - func test_advanceIfTypedCharactersMatch_returnsNilForEmptyInput() { - // An empty capture is not a text mutation; advancing by zero would silently re-validate a - // session that no key event actually confirmed. - let session = CotabbyTestFixtures.activeSession(fullText: " world again") - - XCTAssertNil(SuggestionSessionReconciler.advanceIfTypedCharactersMatch("", session: session)) - } - - func test_nextAcceptanceChunk_includesLeadingWhitespaceAndNextVisibleToken() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: " world again"), - " world" - ) - } - - func test_nextAcceptanceChunk_returnsSingleTokenWhenNoLeadingWhitespace() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "world again"), - "world" - ) - } - - func test_nextAcceptanceChunk_returnsEmptyForEmptyTail() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: ""), "") - } - - func test_nextAcceptanceChunk_defaultsToAcceptingTrailingPunctuation() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?"), "you?") - } - - func test_nextAcceptanceChunk_keepsTrailingPunctuationWhenAutoAcceptEnabled() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?", autoAcceptTrailingPunctuation: true), - "you?" - ) - } - - func test_nextAcceptanceChunk_splitsTrailingPunctuationWhenAutoAcceptDisabled() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?", autoAcceptTrailingPunctuation: false), - "you" - ) - } - - func test_nextAcceptanceChunk_returnsLeftoverPunctuationAsItsOwnPart() { - // After "you" is accepted, the remaining tail is the bare punctuation, taken whole next. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "?", autoAcceptTrailingPunctuation: false), - "?" - ) - } - - func test_nextAcceptanceChunk_splitsMultipleTrailingMarksAsOnePart() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?!", autoAcceptTrailingPunctuation: false), - "you" - ) - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "?!", autoAcceptTrailingPunctuation: false), - "?!" - ) - } - - func test_nextAcceptanceChunk_preservesInternalPunctuationWhenSplitting() { - // Apostrophes and interior dots are not trailing, so the word stays whole. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "don't", autoAcceptTrailingPunctuation: false), - "don't" - ) - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A", autoAcceptTrailingPunctuation: false), - "U.S.A" - ) - } - - func test_nextAcceptanceChunk_splitsOnlyFinalPeriodAfterInteriorDots() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A.", autoAcceptTrailingPunctuation: false), - "U.S.A" - ) - } - - func test_nextAcceptanceChunk_keepsLeadingWhitespaceWhenSplittingPunctuation() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: " world!", autoAcceptTrailingPunctuation: false), - " world" - ) - } - - func test_nextAcceptanceChunk_splittingStopsAtFirstWhitespaceBoundary() { - // The first token has no trailing punctuation, so splitting leaves it whole and never - // reaches the punctuation on the following word. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "hello world?", autoAcceptTrailingPunctuation: false), - "hello" - ) - } - - // MARK: - Space-less-script word acceptance - - func test_nextAcceptanceChunk_latinAcceptanceIsUnchangedBySpacelessBranch() { - // Regression guard: the space-less branch must never alter space-delimited acceptance. - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "hello world"), "hello") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "don't stop now"), "don't") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A today"), "U.S.A") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "1.5 times"), "1.5") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "café René"), "café") - // A space-less script appearing later in the tail must not pull the first Latin token early. - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "world 你好"), "world") - } - - func test_nextAcceptanceChunk_segmentsChineseBelowWholeLength() { - // ICU word segmentation may be per-character or dictionary-based depending on the OS, so this - // asserts the robust property (accept one segment, not the whole run) rather than a pinned word. - let run = "你好世界" - let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) - XCTAssertFalse(chunk.isEmpty) - XCTAssertTrue(run.hasPrefix(chunk)) - XCTAssertLessThan(chunk.count, run.count, "a space-less Chinese run must segment, not accept the whole run") - } - - func test_nextAcceptanceChunk_segmentsJapaneseRunBelowWholeLength() { - let run = "今日はいい天気です" - let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) - XCTAssertFalse(chunk.isEmpty) - XCTAssertTrue(run.hasPrefix(chunk)) - XCTAssertLessThan(chunk.count, run.count, "a space-less Japanese run must segment, not accept whole") - } - - func test_nextAcceptanceChunk_segmentsThaiRunBelowWholeLength() { - let run = "สวัสดีครับ" - let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) - XCTAssertFalse(chunk.isEmpty) - XCTAssertTrue(run.hasPrefix(chunk)) - XCTAssertLessThan(chunk.count, run.count, "a space-less Thai run must segment, not accept whole") - } - - func test_nextAcceptanceChunk_chineseAcceptanceStaysWithinRunBeforeSpace() { - let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: "你好 world") - XCTAssertFalse(chunk.isEmpty) - XCTAssertTrue("你好".hasPrefix(chunk), "acceptance must stay within the CJK run and not cross the space") - XCTAssertFalse(chunk.contains(" ")) - } - - func test_nextAcceptanceChunk_keepsLeadingWhitespaceBeforeSpacelessWord() { - let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: " 你好世界") - XCTAssertTrue(chunk.hasPrefix(" "), "leading whitespace is preserved before the segmented word") - let afterSpace = String(chunk.dropFirst()) - XCTAssertFalse(afterSpace.isEmpty) - XCTAssertTrue("你好世界".hasPrefix(afterSpace)) - XCTAssertLessThan(afterSpace.count, 4, "only the first segment is accepted, not the whole run") - } - - // MARK: - Phrase chunker - - func test_nextAcceptancePhrase_returnsEmptyForEmptyTail() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: ""), "") - } - - func test_nextAcceptancePhrase_returnsWholeTailWhenNoTerminatorPresent() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world again"), - "hello world again" - ) - } - - func test_nextAcceptancePhrase_stopsAtFirstPeriod() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world. foo bar."), - "hello world." - ) - } - - func test_nextAcceptancePhrase_stopsAtFirstQuestionMark() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "how are you? fine."), - "how are you?" - ) - } - - func test_nextAcceptancePhrase_stopsAtFirstExclamation() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "stop! go back"), - "stop!" - ) - } - - func test_nextAcceptancePhrase_stopsAtNewlineBetweenTokens() { - // Composition over the word chunker would otherwise carry the newline as leading whitespace - // into the next iteration's accumulated chunk; the in-chunk newline scan must catch it. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello\nworld"), - "hello\n" - ) - } - - func test_nextAcceptancePhrase_stopsAtLeadingNewline() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\nworld"), - "\n" - ) - } - - func test_nextAcceptancePhrase_stopsAtFirstOfMultipleNewlines() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\n\nbody"), - "\n" - ) - } - - func test_nextAcceptancePhrase_includesLeadingWhitespaceUpToTerminator() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: " hello. world."), - " hello." - ) - } - - func test_nextAcceptancePhrase_preservesInteriorPunctuationWithinTokens() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "don't go. yes"), - "don't go." - ) - } - - // MARK: - CJK phrase boundaries - - /// The reported case: a space-less Japanese sentence must not arrive as one giant Tab. The - /// ideographic comma is a clause boundary, so phrase accepts advance clause by clause. - func test_nextAcceptancePhrase_stopsAtIdeographicComma() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "理解し、その内容を自分の言葉で表現する。"), - "理解し、" - ) - } - - func test_nextAcceptancePhrase_stopsAtIdeographicFullStop() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "その内容を自分の言葉で表現する。次の文"), - "その内容を自分の言葉で表現する。" - ) - } - - func test_nextAcceptancePhrase_stopsAtFullwidthExclamationAndQuestion() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "すごい!次へ"), "すごい!") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "いいですか?はい"), "いいですか?") - } - - /// The closer-walk must work for CJK quotes too: the accumulated tail is `」`, and the - /// terminator underneath is the ideographic full stop. - func test_nextAcceptancePhrase_walksPastCJKClosingQuote() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "終わり。」次の文"), - "終わり。」" - ) - } - - /// ASCII commas must stay non-boundaries so English phrase cadence is unchanged by the CJK rules. - func test_nextAcceptancePhrase_doesNotStopAtAsciiComma() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello, world. next"), - "hello, world." - ) - } - - // MARK: - CJK punctuation binding in word chunks - - /// Trailing CJK punctuation binds to the word it follows, so one Tab accepts the word and its - /// comma as a unit instead of stranding the comma to lead the next chunk. - func test_nextAcceptanceChunk_bindsTrailingIdeographicCommaToWord() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "資料、内容"), "資料、") - } - - /// A punctuation-led tail peels the punctuation run as its own chunk. Before this rule the token - /// skipped ICU segmentation (punctuation does not begin a space-less-script word) and the accept - /// swallowed everything up to the next whitespace in one chunk. - func test_nextAcceptanceChunk_peelsLeadingCJKPunctuationRunInsteadOfSwallowingTheTail() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "、理解し、その内容"), "、") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "。」次の文"), "。」") - } - - /// CJK opening brackets are peeled too: `「` leads the word it quotes, so it neither begins a - /// space-less-script word nor binds to the preceding one, and without the peel a quoted run in - /// flat text would be swallowed whole (`「分かった」と言った` after `は` in one Tab). - func test_nextAcceptanceChunk_peelsLeadingCJKOpeningBracket() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "「分かった」と言った"), "「") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "【内容】次"), "【") - } - - /// A mixed close-then-open run (`。」「`) peels as one punctuation chunk, so back-to-back quotes - /// never strand the walker. - func test_nextAcceptanceChunk_peelsMixedCloserOpenerRunAsOneChunk() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "。」「次の文"), "。」「") - } - - /// The katakana middle dot lives in the kana block, so it enters the ICU branch, but a run of - /// middle dots contains no segmentable word. The chunker must fall back to the whole - /// whitespace-bounded token rather than producing an empty chunk and stalling. - func test_nextAcceptanceChunk_kanaPunctuationRunWithoutWordsAcceptsWholeToken() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・ あと"), "・・・") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・"), "・・・") - } - - /// The trailing binding must stop before an opening bracket: the closer and full stop belong to - /// the word, but the next quote's opener belongs to the next word. - func test_nextAcceptanceChunk_trailingBindingStopsBeforeOpeningBracket() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "内容。「次"), "内容。") - } - - /// Halfwidth kana punctuation (legacy SJIS contexts) behaves like its fullwidth counterparts: - /// the halfwidth comma is a clause boundary and the halfwidth corner bracket binds and walks. - func test_halfwidthKanaPunctuation_matchesFullwidthBehavior() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "資料を読み、次へ"), "資料を読み、") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptancePhrase(from: "終わり。」次の文"), "終わり。」") - } - - /// ASCII brackets and quotes must keep their existing whole-token behavior: the CJK opener peel - /// is scoped to CJK codepoints, so space-delimited scripts stay byte-for-byte unchanged. - func test_nextAcceptanceChunk_asciiBracketsUnchangedByOpenerPeel() { - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "(hello) world"), "(hello)") - XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "\"quote\" next"), "\"quote\"") - } - - // MARK: - CJK punctuation under trailing-punctuation policy - - /// With trailing-punctuation auto-accept off, the CJK binding is intentionally re-peeled: the word - /// accepts on its own and the clause comma waits for the next Tab, exactly how ASCII trailing - /// punctuation behaves under the same setting. The binding is a no-op in this path by design. - func test_nextAcceptanceChunk_autoAcceptOff_trimsBoundCJKCommaBackOffTheWord() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "資料、内容", autoAcceptTrailingPunctuation: false), - "資料" - ) - } - - /// A punctuation-led peel must stay non-empty with auto-accept off. Trimming would otherwise strip - /// the whole chunk and stall the phrase walker, but `wordEndTrimmingTrailingPunctuation` returns - /// nil for a punctuation-only token, so the comma survives as its own chunk. - func test_nextAcceptanceChunk_autoAcceptOff_keepsPunctuationOnlyPeelNonEmpty() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptanceChunk(from: "、内容", autoAcceptTrailingPunctuation: false), - "、" - ) - } - - /// The flag never changes phrase output: with auto-accept off the word and comma arrive as separate - /// chunks, but they accumulate to the same clause the flag-on path returns in one binding. - func test_nextAcceptancePhrase_autoAcceptOff_stillStopsAtIdeographicComma() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase( - from: "理解し、その内容を自分の言葉で表現する。", - autoAcceptTrailingPunctuation: false - ), - "理解し、" - ) - } - - func test_nextAcceptancePhrase_walksPastDottedInitialsToRealSentenceEnd() { - // "U.S.A." is a run of single-letter initials, so its interior periods are not sentence - // ends. SentenceBoundaryClassifier keeps phrase acceptance going until the real terminator - // after "great" (see SentenceBoundaryClassifierTests for the period-disambiguation rules). - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "U.S.A. is great."), - "U.S.A. is great." - ) - } - - func test_nextAcceptancePhrase_isInvariantToAutoAcceptTrailingPunctuationFlag() { - let tail = "you? Yes." - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: tail, autoAcceptTrailingPunctuation: true), - "you?" - ) - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: tail, autoAcceptTrailingPunctuation: false), - "you?" - ) - } - - func test_nextAcceptancePhrase_stopsAtNewlineEvenWhenPunctuationPrecedes() { - // The newline must win over a sentence-terminator on the same line so paragraph breaks are - // never accidentally skipped past. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "hello world\nmore"), - "hello world\n" - ) - } - - func test_nextAcceptancePhrase_stopsAtSentenceEndInsideStraightQuotes() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"done.\" Next sentence."), - "\"done.\"" - ) - } - - func test_nextAcceptancePhrase_stopsAtSentenceEndInsideCurlyQuotes() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\u{201C}done.\u{201D} Next."), - "\u{201C}done.\u{201D}" - ) - } - - func test_nextAcceptancePhrase_stopsAtSentenceEndInsideParentheses() { - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "(yes!) keep going"), - "(yes!)" - ) - } - - func test_nextAcceptancePhrase_walksPastMultipleClosingPunctuation() { - // Nested closers — quote inside parens, sentence ends inside both. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "(\"done.\") next"), - "(\"done.\")" - ) - } - - func test_nextAcceptancePhrase_doesNotBreakOnBareClosingQuote() { - // Closing quote with no preceding sentence terminator is not a phrase boundary. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"hi\" there"), - "\"hi\" there" - ) - } - - func test_nextAcceptancePhrase_chunkOfOnlyClosingPunctuationIsNotABoundary() { - // The closer walk-back can consume the entire accumulated chunk; with no character left - // underneath there is no terminator, so the phrase must keep accumulating. - XCTAssertEqual( - SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"\" hello"), - "\"\" hello" - ) - } - - func test_insertionChunk_dropsLeadingSpaceWhenPrecedingTextAlreadyEndsInWhitespace() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are "), - "you" - ) - } - - func test_insertionChunk_keepsLeadingSpaceWhenPrecedingTextHasNoTrailingWhitespace() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are"), - " you" - ) - } - - func test_insertionChunk_collapsesAWholeLeadingRunAgainstFieldWhitespace() { - // The reported "bunch of spaces" case: a field that already ends in a space plus a chunk - // carrying its own leading space(s) must not stack them. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are "), - "you" - ) - } - - func test_insertionChunk_leavesChunkUntouchedWhenItHasNoLeadingWhitespace() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "you", precedingText: "How are "), - "you" - ) - } - - func test_insertionChunk_treatsTabAsBoundaryWhitespaceButNotNewline() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are\t"), - "you" - ) - // Newlines are not horizontal whitespace, so a leading space after a line break is kept. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "line\n"), - " you" - ) - } - - func test_insertionChunk_preservesInterWordSpaceMidSuggestion() { - // After "you" was already inserted, the field ends in a word, so the next chunk's space - // is the real boundary and must survive. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " are", precedingText: "How are you"), - " are" - ) - } - - func test_insertionChunk_returnsChunkUnchangedForEmptyPrecedingText() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: ""), - " you" - ) - } - - func test_insertionChunk_continuesPartialWordWhenModelOmitsLeadingSpace() { - // Regression for issue #621 ("after" -> "afternoon" committing as "after noon"): the caret - // sits at the end of a partial word and the model continues it with no leading space. We type - // the continuation verbatim so it glues into one word instead of synthesizing a boundary. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "noon", precedingText: "after"), - "noon" - ) - } - - func test_insertionChunk_trustsModelAndDoesNotSynthesizeBoundary() { - // Trust-the-model: when the chunk has no leading space and the field ends in a word - // character, we no longer insert one. A genuine new word arrives with the model's own leading - // space (see `keepsLeadingSpaceWhenPrecedingTextHasNoTrailingWhitespace`); when the model - // omits it the words glue, which is exactly what the ghost text showed, so accept stays - // WYSIWYG. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "Hello"), - "World" - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "world", precedingText: "the"), - "world" - ) - } - - func test_insertionChunk_doesNotSynthesizeBoundaryAcrossDigitWordBoundary() { - // Same trust-the-model contract across a digit/letter boundary: no synthesized separator, so - // the model decides whether "123" continues into "abc" or stands apart. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "abc", precedingText: "123"), - "abc" - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "1st", precedingText: "Hello"), - "1st" - ) - } - - func test_insertionChunk_doesNotAddBoundarySpaceWhenChunkStartsWithPunctuation() { - // Punctuation-leading chunks ("." closes a sentence, "'s" is a possessive, "," is a list - // continuation) intentionally attach to the prior word without a separator. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: ".", precedingText: "Hello"), - "." - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "'s", precedingText: "John"), - "'s" - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: ", more", precedingText: "first"), - ", more" - ) - } - - func test_insertionChunk_doesNotAddBoundarySpaceAfterPunctuation() { - // Opening punctuation in the prefix means the chunk should hug it, not be separated from it. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "Hello ("), - "World" - ) - } - - func test_insertionChunk_doesNotAddBoundarySpaceAfterNewline() { - // A line break is a hard boundary on its own; we should not synthesize an indent space here. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: "line\n"), - "World" - ) - } - - func test_insertionChunk_doesNotAddBoundarySpaceWhenPrecedingTextIsEmpty() { - // At the very start of an empty field there is no last word to glue onto. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "World", precedingText: ""), - "World" - ) - } - - func test_insertionChunk_dropsLeadingHorizontalWhitespaceButNotLeadingNewline() { - // The drop predicate must mirror the guard's horizontal-whitespace definition, so a chunk - // whose first character is a newline survives even when the field ends in a space — keeping - // the structural line break the suggestion was authored with. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: "\nnext", precedingText: "first "), - "\nnext" - ) - } - - func test_insertionChunkAppendingTrailingSpace_appendsAfterFinishedWord() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello"), - "hello " - ) - } - - func test_insertionChunkAppendingTrailingSpace_appendsAfterTrailingDigit() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("section 12"), - "section 12 " - ) - } - - func test_insertionChunkAppendingTrailingSpace_skipsWhenEndingInPunctuation() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("done."), - "done." - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("really?!"), - "really?!" - ) - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("(yes)"), - "(yes)" - ) - } - - func test_insertionChunkAppendingTrailingSpace_skipsWhenAlreadyEndingInWhitespace() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("hello "), - "hello " - ) - } - - func test_insertionChunkAppendingTrailingSpace_skipsForSpacelessScript() { - // CJK glyphs are letters, but their scripts never separate words with spaces, so a trailing - // space would be wrong. The space-less-script guard suppresses it. - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace("資料"), - "資料" - ) - } - - func test_insertionChunkAppendingTrailingSpace_leavesEmptyChunkUntouched() { - XCTAssertEqual( - SuggestionSessionReconciler.insertionChunkAppendingTrailingSpace(""), - "" - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_takesFollowingSpaceAfterWord() { - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world how are you"), - "world " - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_keepsLeadingWhitespaceAndTakesFollowingSpace() { - // nextAcceptanceChunk returns leading whitespace with the token, so the extension must keep it - // and still consume the space that follows the word. - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace(" world", remainingText: " world how"), - " world " - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_takesWholeHorizontalRun() { - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world\t how"), - "world\t " - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_noFollowingWhitespaceLeavesChunkUntouched() { - // End of the suggestion: nothing to consume here — the exhaustion-time append covers it. - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world"), - "world" - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_doesNotCrossNewline() { - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("line", remainingText: "line\nnext"), - "line" - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_doesNotConsumeBeforePunctuation() { - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("world", remainingText: "world, how"), - "world" - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_skipsWhenChunkEndsInPunctuation() { - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("done.", remainingText: "done. next"), - "done." - ) - } - - func test_acceptanceChunkConsumingTrailingSpace_skipsForSpacelessScript() { - // CJK scripts do not separate words with spaces, so even a stray following space is not taken. - XCTAssertEqual( - SuggestionSessionReconciler.acceptanceChunkConsumingTrailingSpace("資料", remainingText: "資料 です"), - "資料" - ) - } - - func test_acceptedWordCount_countsOnlyTokensWithAlphanumerics() { - let count = SuggestionSessionReconciler.acceptedWordCount( - in: "hello, !!! world 123 --" - ) - - XCTAssertEqual(count, 3) - } - - func test_overlayAllowsAcceptance_trueWhenOverlayHidden() { - XCTAssertTrue( - SuggestionSessionReconciler.overlayAllowsAcceptance( - of: " world", - overlayState: .hidden(reason: "waiting for AX") - ) - ) - } - - func test_overlayAllowsAcceptance_trueOnlyWhenVisibleTextMatches() { - let caretRect = CGRect(x: 10, y: 20, width: 2, height: 18) - - XCTAssertTrue( - SuggestionSessionReconciler.overlayAllowsAcceptance( - of: " world", - overlayState: .visible( - text: " world", - geometry: CotabbyTestFixtures.overlayGeometry(caretRect: caretRect), - mode: .inline - ) - ) - ) - XCTAssertFalse( - SuggestionSessionReconciler.overlayAllowsAcceptance( - of: " world", - overlayState: .visible( - text: " there", - geometry: CotabbyTestFixtures.overlayGeometry(caretRect: caretRect), - mode: .inline - ) - ) - ) - } - - func test_overlayHideReason_mapsSemanticInputEventsToUserVisibleReasons() { - XCTAssertEqual( - SuggestionSessionReconciler.overlayHideReason( - for: CotabbyTestFixtures.inputEvent(kind: .textMutation) - ), - "Overlay hidden because typing invalidated the current suggestion." - ) - XCTAssertEqual( - SuggestionSessionReconciler.overlayHideReason( - for: CotabbyTestFixtures.inputEvent(kind: .navigation) - ), - "Overlay hidden because caret navigation invalidated the current suggestion." - ) - XCTAssertEqual( - SuggestionSessionReconciler.overlayHideReason( - for: CotabbyTestFixtures.inputEvent(kind: .dismissal) - ), - "Overlay hidden because a dismissal key was pressed." - ) - } - - func test_overlayHideReason_acceptanceAndOtherEventsUseTheGenericReason() { - // Acceptance-driven hides are expected behavior, not invalidation, so they get the plain - // message; shortcut mutations read as typing. - for kind in [CapturedInputEvent.Kind.acceptance, .fullAcceptance, .other] { - XCTAssertEqual( - SuggestionSessionReconciler.overlayHideReason( - for: CotabbyTestFixtures.inputEvent(kind: kind) - ), - "Overlay hidden." - ) - } - XCTAssertEqual( - SuggestionSessionReconciler.overlayHideReason( - for: CotabbyTestFixtures.inputEvent(kind: .shortcutMutation) - ), - "Overlay hidden because typing invalidated the current suggestion." - ) - } - - func test_reconcile_validWhenLiveContextStillMatchesBaseContext() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - basePrecedingText: "Hello", - baseTrailingText: " tail" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext( - precedingText: "Hello", - trailingText: " tail" - ) - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected valid reconciliation") - return - } - XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) - XCTAssertEqual(reconciledSession.remainingText, session.remainingText) - XCTAssertNil(advancement) - XCTAssertNil(nextPending) - } - - func test_reconcile_invalidWhenProcessChanges() { - let session = CotabbyTestFixtures.activeSession(processIdentifier: 123) - let liveContext = CotabbyTestFixtures.focusedInputContext(processIdentifier: 456) - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid( - reconciliation, - reason: "Overlay hidden because the focused field changed." - ) - } - - func test_reconcile_invalidWhenTextIsSelected() { - let session = CotabbyTestFixtures.activeSession() - let liveContext = CotabbyTestFixtures.focusedInputContext( - selection: NSRange(location: 1, length: 2) - ) - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid(reconciliation, reason: "Overlay hidden because text is selected.") - } - - func test_reconcile_invalidWhenTrailingTextChangesOutsideInsertionSyncWindow() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - basePrecedingText: "Hello", - baseTrailingText: " tail" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext( - precedingText: "Hello", - trailingText: " changed" - ) - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid( - reconciliation, - reason: "Overlay hidden because text after the caret changed." - ) - } - - func test_reconcile_toleratesTrailingTextRaceAfterAcceptedInsertion() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 6, - basePrecedingText: "Hello", - baseTrailingText: " tail" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext( - precedingText: "Hello", - trailingText: " changed" - ) - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: 6 - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected transient insertion lag to be tolerated") - return - } - XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) - XCTAssertEqual(reconciledSession.remainingText, session.remainingText) - XCTAssertNil(advancement) - XCTAssertEqual(nextPending, 6) - } - - func test_reconcile_invalidWhenPrefixAnchorChangesOutsideInsertionSyncWindow() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Goodbye") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid( - reconciliation, - reason: "Overlay hidden because text before the caret no longer matches the suggestion anchor." - ) - } - - func test_reconcile_invalidWhenConsumedSuffixDivergesFromSuggestion() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello there") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid( - reconciliation, - reason: "Overlay hidden because typed text diverged from the active suggestion." - ) - } - - func test_reconcile_advancesSessionWhenLiveTextConsumedSuggestionPrefix() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello world") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected consumed suggestion text to advance the session") - return - } - XCTAssertEqual(reconciledSession.acceptedText, " world") - XCTAssertEqual(reconciledSession.remainingText, " again") - XCTAssertEqual(advancement?.stage, "session-reconciled") - XCTAssertNil(nextPending) - } - - func test_reconcile_invalidWhenSuggestionPartiallyUndoneOutsideInsertionSyncWindow() { - // The session has consumed " worl" (5 chars) but the live field only shows " wo": the user - // deleted part of the accepted text, so the session must die. - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 5, - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: nil - ) - - assertInvalid( - reconciliation, - reason: "Overlay hidden because the active suggestion was partially undone." - ) - } - - func test_reconcile_toleratesShorterConsumedSuffixRightAfterAcceptedInsertion() { - // Same field state as the undo case, but we just Tab-inserted up to 5 consumed characters - // (the sentinel matches): AX simply has not published the full insert yet, so the session - // must survive untouched for one more cycle. - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 5, - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: 5 - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected post-insertion AX lag to be tolerated") - return - } - XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) - XCTAssertEqual(reconciledSession.remainingText, session.remainingText) - XCTAssertNil(advancement) - XCTAssertEqual(nextPending, 5) - } - - func test_reconcile_toleratesPrefixAnchorRaceRightAfterAcceptedInsertion() { - // Inverse Chromium race: trailing text already stable, but the prefix still reflects the - // pre-insertion snapshot. With the sentinel armed the session waits instead of dying. - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 6, - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Goodbye") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: 6 - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected prefix-anchor race to be tolerated during the insertion sync window") - return - } - XCTAssertEqual(reconciledSession.remainingText, session.remainingText) - XCTAssertNil(advancement) - XCTAssertEqual(nextPending, 6) - } - - func test_reconcile_toleratesConsumedSuffixDivergenceRightAfterAcceptedInsertion() { - // The preceding text grew with characters that do not match the suggestion: outside the - // sync window that is invalidating, but right after Tab it is just stale AX content. - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 6, - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Helloxyz") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: 6 - ) - - guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { - XCTFail("Expected consumed-suffix divergence to be tolerated during the insertion sync window") - return - } - XCTAssertEqual(reconciledSession.remainingText, session.remainingText) - XCTAssertNil(advancement) - XCTAssertEqual(nextPending, 6) - } - - func test_reconcile_clearsPendingInsertionSentinelWhenAXCatchesUp() { - let session = CotabbyTestFixtures.activeSession( - fullText: " world again", - consumedCharacterCount: 6, - basePrecedingText: "Hello" - ) - let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello world") - - let reconciliation = SuggestionSessionReconciler.reconcile( - session: session, - with: liveContext, - pendingInsertionConsumedCount: 6 - ) - - guard case let .valid(_, _, nextPending) = reconciliation else { - XCTFail("Expected caught-up AX state to remain valid") - return - } - XCTAssertNil(nextPending) - } - - func test_isStaleAcceptanceEcho_dropsRepeatOfAcceptedTailWhileFieldUnchanged() { - XCTAssertTrue( - SuggestionSessionReconciler.isStaleAcceptanceEcho( - resultText: " today", - acceptedChunk: " today", - currentPrecedingText: "what's on your mind", - acceptedPrecedingText: "what's on your mind" - ) - ) - } - - func test_isStaleAcceptanceEcho_toleratesLeadingWhitespaceDifference() { - XCTAssertTrue( - SuggestionSessionReconciler.isStaleAcceptanceEcho( - resultText: "today", - acceptedChunk: " today", - currentPrecedingText: "what's on your mind", - acceptedPrecedingText: "what's on your mind" - ) - ) - } - - func test_isStaleAcceptanceEcho_allowsSuggestionOnceTheInsertPublished() { - XCTAssertFalse( - SuggestionSessionReconciler.isStaleAcceptanceEcho( - resultText: " today", - acceptedChunk: " today", - currentPrecedingText: "what's on your mind today", - acceptedPrecedingText: "what's on your mind" - ) - ) - } - - func test_isStaleAcceptanceEcho_allowsGenuinelyDifferentContinuation() { - XCTAssertFalse( - SuggestionSessionReconciler.isStaleAcceptanceEcho( - resultText: " tomorrow", - acceptedChunk: " today", - currentPrecedingText: "what's on your mind", - acceptedPrecedingText: "what's on your mind" - ) - ) - } - - func test_isStaleAcceptanceEcho_ignoresWhitespaceOnlyAcceptedChunk() { - XCTAssertFalse( - SuggestionSessionReconciler.isStaleAcceptanceEcho( - resultText: " ", - acceptedChunk: " ", - currentPrecedingText: "what's on your mind", - acceptedPrecedingText: "what's on your mind" - ) - ) - } - - private func assertInvalid( - _ reconciliation: SuggestionSessionReconciliation, - reason expectedReason: String, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard case let .invalid(reason) = reconciliation else { - XCTFail("Expected invalid reconciliation", file: file, line: line) - return - } - - XCTAssertEqual(reason, expectedReason, file: file, line: line) - } -} diff --git a/CotabbyTests/Support/Suggestion/SuggestionSessionReconciliationTests.swift b/CotabbyTests/Support/Suggestion/SuggestionSessionReconciliationTests.swift new file mode 100644 index 00000000..e796b3df --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionSessionReconciliationTests.swift @@ -0,0 +1,309 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionSessionReconciliationTests: XCTestCase { + func test_reconcile_validWhenLiveContextStillMatchesBaseContext() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + basePrecedingText: "Hello", + baseTrailingText: " tail" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext( + precedingText: "Hello", + trailingText: " tail" + ) + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected valid reconciliation") + return + } + XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertNil(nextPending) + } + + func test_reconcile_invalidWhenProcessChanges() { + let session = CotabbyTestFixtures.activeSession(processIdentifier: 123) + let liveContext = CotabbyTestFixtures.focusedInputContext(processIdentifier: 456) + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because the focused field changed." + ) + } + + func test_reconcile_invalidWhenTextIsSelected() { + let session = CotabbyTestFixtures.activeSession() + let liveContext = CotabbyTestFixtures.focusedInputContext( + selection: NSRange(location: 1, length: 2) + ) + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid(reconciliation, reason: "Overlay hidden because text is selected.") + } + + func test_reconcile_invalidWhenTrailingTextChangesOutsideInsertionSyncWindow() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + basePrecedingText: "Hello", + baseTrailingText: " tail" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext( + precedingText: "Hello", + trailingText: " changed" + ) + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because text after the caret changed." + ) + } + + func test_reconcile_toleratesTrailingTextRaceAfterAcceptedInsertion() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello", + baseTrailingText: " tail" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext( + precedingText: "Hello", + trailingText: " changed" + ) + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected transient insertion lag to be tolerated") + return + } + XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 6) + } + + func test_reconcile_invalidWhenPrefixAnchorChangesOutsideInsertionSyncWindow() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Goodbye") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because text before the caret no longer matches the suggestion anchor." + ) + } + + func test_reconcile_invalidWhenConsumedSuffixDivergesFromSuggestion() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello there") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because typed text diverged from the active suggestion." + ) + } + + func test_reconcile_advancesSessionWhenLiveTextConsumedSuggestionPrefix() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello world") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected consumed suggestion text to advance the session") + return + } + XCTAssertEqual(reconciledSession.acceptedText, " world") + XCTAssertEqual(reconciledSession.remainingText, " again") + XCTAssertEqual(advancement?.stage, "session-reconciled") + XCTAssertNil(nextPending) + } + + func test_reconcile_invalidWhenSuggestionPartiallyUndoneOutsideInsertionSyncWindow() { + // The session has consumed " worl" (5 chars) but the live field only shows " wo": the user + // deleted part of the accepted text, so the session must die. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 5, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because the active suggestion was partially undone." + ) + } + + func test_reconcile_toleratesShorterConsumedSuffixRightAfterAcceptedInsertion() { + // Same field state as the undo case, but we just Tab-inserted up to 5 consumed characters + // (the sentinel matches): AX simply has not published the full insert yet, so the session + // must survive untouched for one more cycle. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 5, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 5 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected post-insertion AX lag to be tolerated") + return + } + XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 5) + } + + func test_reconcile_toleratesPrefixAnchorRaceRightAfterAcceptedInsertion() { + // Inverse Chromium race: trailing text already stable, but the prefix still reflects the + // pre-insertion snapshot. With the sentinel armed the session waits instead of dying. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Goodbye") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected prefix-anchor race to be tolerated during the insertion sync window") + return + } + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 6) + } + + func test_reconcile_toleratesConsumedSuffixDivergenceRightAfterAcceptedInsertion() { + // The preceding text grew with characters that do not match the suggestion: outside the + // sync window that is invalidating, but right after Tab it is just stale AX content. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Helloxyz") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected consumed-suffix divergence to be tolerated during the insertion sync window") + return + } + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 6) + } + + func test_reconcile_clearsPendingInsertionSentinelWhenAXCatchesUp() { + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello world") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(_, _, nextPending) = reconciliation else { + XCTFail("Expected caught-up AX state to remain valid") + return + } + XCTAssertNil(nextPending) + } + + private func assertInvalid( + _ reconciliation: SuggestionSessionReconciliation, + reason expectedReason: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case let .invalid(reason) = reconciliation else { + XCTFail("Expected invalid reconciliation", file: file, line: line) + return + } + + XCTAssertEqual(reason, expectedReason, file: file, line: line) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionSessionTypingTests.swift b/CotabbyTests/Support/Suggestion/SuggestionSessionTypingTests.swift new file mode 100644 index 00000000..4d0d6d03 --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionSessionTypingTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionSessionTypingTests: XCTestCase { + func test_advanceIfTypedCharactersMatch_advancesMatchingDirectText() { + let session = CotabbyTestFixtures.activeSession(fullText: " world again") + + let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( + " world", + session: session + ) + + XCTAssertEqual(advanced?.acceptedText, " world") + XCTAssertEqual(advanced?.remainingText, " again") + } + + func test_advanceIfTypedCharactersMatch_returnsNilForDivergentText() { + let session = CotabbyTestFixtures.activeSession(fullText: " world again") + + let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( + " there", + session: session + ) + + XCTAssertNil(advanced) + } + + func test_advanceIfTypedCharactersMatch_returnsNilForControlCharacters() { + let session = CotabbyTestFixtures.activeSession(fullText: " world again") + + let advanced = SuggestionSessionReconciler.advanceIfTypedCharactersMatch( + "\n", + session: session + ) + + XCTAssertNil(advanced) + } + + func test_advanceIfTypedCharactersMatch_returnsNilForEmptyInput() { + // An empty capture is not a text mutation; advancing by zero would silently re-validate a + // session that no key event actually confirmed. + let session = CotabbyTestFixtures.activeSession(fullText: " world again") + + XCTAssertNil(SuggestionSessionReconciler.advanceIfTypedCharactersMatch("", session: session)) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionSettingsModelDisabledAppsTests.swift b/CotabbyTests/Support/Suggestion/SuggestionSettingsModelDisabledAppsTests.swift new file mode 100644 index 00000000..5ccef8d2 --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionSettingsModelDisabledAppsTests.swift @@ -0,0 +1,328 @@ +import Combine +import CoreGraphics +import XCTest +@testable import Cotabby + +/// Tests for the durable disabled-app blocklist. +/// +/// These live beside the evaluator tests because the two pieces form one contract: settings own +/// persistence, while the evaluator consumes the snapshot produced from those settings. +final class SuggestionSettingsModelDisabledAppsTests: XCTestCase { + /// Hosted macOS tests are currently crashing while deallocating short-lived + /// `SuggestionSettingsModel` instances. Retaining the models for the full process lifetime + /// quarantines that runtime issue so these tests can keep asserting the persistence contract. + private static var retainedModels: [SuggestionSettingsModel] = [] + + /// Keep the suite object and its name together so teardown clears the exact domain each test + /// created. This avoids reaching back through `UserDefaults.standard`, which is a broader + /// global API surface than these tests actually need. + private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] + + override func tearDown() { + for suite in userDefaultsSuites { + suite.userDefaults.removePersistentDomain(forName: suite.suiteName) + } + userDefaultsSuites.removeAll() + super.tearDown() + } + + func test_disabledAppRules_surviveModelRecreation() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + model.disableApplication( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari" + ) + + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertEqual( + reloadedModel.disabledAppRules, + [ + DisabledApplicationRule( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari" + ) + ] + ) + } + } + + func test_disableApplication_reusesBundleIdentifierInsteadOfDuplicating() { + runOnMainActor { + let model = makeModel() + + model.disableApplication( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari" + ) + model.disableApplication( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari Technology Preview" + ) + + XCTAssertEqual(model.disabledAppRules.count, 1) + XCTAssertEqual( + model.disabledAppRules.first?.displayName, + "Safari Technology Preview" + ) + } + } + + func test_removeDisabledApplication_deletesOnlyMatchingBundleIdentifier() { + runOnMainActor { + let model = makeModel() + + model.disableApplication( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari" + ) + model.disableApplication( + bundleIdentifier: "com.tinyspeck.slackmacgap", + displayName: "Slack" + ) + model.removeDisabledApplication(bundleIdentifier: "com.apple.Safari") + + XCTAssertFalse(model.isApplicationDisabled(bundleIdentifier: "com.apple.Safari")) + XCTAssertTrue( + model.isApplicationDisabled(bundleIdentifier: "com.tinyspeck.slackmacgap") + ) + XCTAssertEqual( + model.disabledAppRules.map(\.bundleIdentifier), + ["com.tinyspeck.slackmacgap"] + ) + } + } + + func test_snapshotPublisher_emitsWhenDisabledAppRulesChange() { + let expectation = expectation(description: "snapshot emits after app rule changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertTrue(snapshot.disabledAppBundleIdentifiers.contains("com.apple.Safari")) + expectation.fulfill() + } + .store(in: &cancellables) + + model.disableApplication( + bundleIdentifier: "com.apple.Safari", + displayName: "Safari" + ) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + + func test_clipboardContextEnabled_defaultsToFalseAndPersists() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + XCTAssertFalse(model.isClipboardContextEnabled) + XCTAssertFalse(model.snapshot.isClipboardContextEnabled) + + model.setClipboardContextEnabled(true) + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertTrue(reloadedModel.isClipboardContextEnabled) + XCTAssertTrue(reloadedModel.snapshot.isClipboardContextEnabled) + } + } + + func test_snapshotPublisher_emitsWhenClipboardContextSettingChanges() { + let expectation = expectation(description: "snapshot emits after clipboard setting changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertTrue(snapshot.isClipboardContextEnabled) + expectation.fulfill() + } + .store(in: &cancellables) + + model.setClipboardContextEnabled(true) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + + func test_fastMode_defaultsToFalseAndPersists() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + XCTAssertFalse(model.isFastModeEnabled) + XCTAssertFalse(model.snapshot.isFastModeEnabled) + + model.setFastModeEnabled(true) + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertTrue(reloadedModel.isFastModeEnabled) + XCTAssertTrue(reloadedModel.snapshot.isFastModeEnabled) + } + } + + func test_snapshotPublisher_emitsWhenFastModeSettingChanges() { + let expectation = expectation(description: "snapshot emits after fast mode setting changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertTrue(snapshot.isFastModeEnabled) + expectation.fulfill() + } + .store(in: &cancellables) + + model.setFastModeEnabled(true) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + + func test_mirrorPreference_defaultsToAutoAndPersists() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + XCTAssertEqual(model.mirrorPreference, .auto) + XCTAssertEqual(model.snapshot.mirrorPreference, .auto) + + model.setMirrorPreference(.alwaysMirror) + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertEqual(reloadedModel.mirrorPreference, .alwaysMirror) + XCTAssertEqual(reloadedModel.snapshot.mirrorPreference, .alwaysMirror) + } + } + + func test_snapshotPublisher_emitsWhenMirrorPreferenceChanges() { + let expectation = expectation(description: "snapshot emits after mirror preference changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertEqual(snapshot.mirrorPreference, .alwaysInline) + expectation.fulfill() + } + .store(in: &cancellables) + + model.setMirrorPreference(.alwaysInline) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + + func test_acceptanceHint_defaultsToOnAndShowsWordAcceptLabel() { + runOnMainActor { + let model = makeModel() + + XCTAssertTrue(model.showAcceptanceHint) + XCTAssertEqual(model.acceptanceHintLabel, SuggestionSettingsModel.defaultAcceptanceKeyLabel) + } + } + + func test_showAcceptanceHint_persistsAcrossModelRecreation() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + model.setShowAcceptanceHint(false) + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertFalse(reloadedModel.showAcceptanceHint) + XCTAssertNil(reloadedModel.acceptanceHintLabel, "Disabled hint should resolve to no label") + } + } + + func test_acceptanceHintLabel_tracksRebindAndFallsBackWhenWordAcceptCleared() { + runOnMainActor { + let model = makeModel() + + model.setAcceptanceKey(keyCode: 49, modifiers: [], label: "Space") + XCTAssertEqual(model.acceptanceHintLabel, "Space", "Hint should follow the rebound word-accept key") + + // Clearing word-accept should fall back to the still-bound full-accept key. + model.clearAcceptanceKey() + XCTAssertEqual(model.acceptanceHintLabel, model.fullAcceptanceKeyLabel) + + // With no accept key bound at all, there is nothing to teach. + model.clearFullAcceptanceKey() + XCTAssertNil(model.acceptanceHintLabel) + } + } + + func test_emojiPickerAcceptKeyLabel_ignoresGhostHintToggleAndRequiresWordAccept() { + runOnMainActor { + let model = makeModel() + + model.setShowAcceptanceHint(false) + XCTAssertEqual(model.emojiPickerAcceptKeyLabel, SuggestionSettingsModel.defaultAcceptanceKeyLabel) + + model.clearAcceptanceKey() + XCTAssertNil(model.emojiPickerAcceptKeyLabel) + } + } + + @MainActor + private func makeModel( + userDefaults: UserDefaults? = nil + ) -> SuggestionSettingsModel { + let model = SuggestionSettingsModel( + configuration: .standard, + userDefaults: userDefaults ?? makeUserDefaults() + ) + Self.retainedModels.append(model) + return model + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "SuggestionSettingsModelDisabledAppsTests-\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Expected an isolated UserDefaults suite") + return .standard + } + + userDefaults.removePersistentDomain(forName: suiteName) + userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) + return userDefaults + } + + /// `MainActor.assumeIsolated` lets the compiler treat the closure as main-actor bound once we + /// have synchronously hopped to the main thread. This keeps the tests deterministic without + /// wrapping each case in a Swift concurrency task, which is the teardown path that was + /// crashing during hosted test execution. + private func runOnMainActor( + _ body: @MainActor () throws -> Result + ) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionStaleAcceptanceEchoTests.swift b/CotabbyTests/Support/Suggestion/SuggestionStaleAcceptanceEchoTests.swift new file mode 100644 index 00000000..3f4a9e3a --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionStaleAcceptanceEchoTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionStaleAcceptanceEchoTests: XCTestCase { + func test_isStaleAcceptanceEcho_dropsRepeatOfAcceptedTailWhileFieldUnchanged() { + XCTAssertTrue( + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: " today", + acceptedChunk: " today", + currentPrecedingText: "what's on your mind", + acceptedPrecedingText: "what's on your mind" + ) + ) + } + + func test_isStaleAcceptanceEcho_toleratesLeadingWhitespaceDifference() { + XCTAssertTrue( + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: "today", + acceptedChunk: " today", + currentPrecedingText: "what's on your mind", + acceptedPrecedingText: "what's on your mind" + ) + ) + } + + func test_isStaleAcceptanceEcho_allowsSuggestionOnceTheInsertPublished() { + XCTAssertFalse( + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: " today", + acceptedChunk: " today", + currentPrecedingText: "what's on your mind today", + acceptedPrecedingText: "what's on your mind" + ) + ) + } + + func test_isStaleAcceptanceEcho_allowsGenuinelyDifferentContinuation() { + XCTAssertFalse( + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: " tomorrow", + acceptedChunk: " today", + currentPrecedingText: "what's on your mind", + acceptedPrecedingText: "what's on your mind" + ) + ) + } + + func test_isStaleAcceptanceEcho_ignoresWhitespaceOnlyAcceptedChunk() { + XCTAssertFalse( + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: " ", + acceptedChunk: " ", + currentPrecedingText: "what's on your mind", + acceptedPrecedingText: "what's on your mind" + ) + ) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionWordAcceptanceTests.swift b/CotabbyTests/Support/Suggestion/SuggestionWordAcceptanceTests.swift new file mode 100644 index 00000000..49ce3116 --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionWordAcceptanceTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import Cotabby + +/// Focused coverage for one responsibility of `SuggestionSessionReconciler`. +final class SuggestionWordAcceptanceTests: XCTestCase { + func test_nextAcceptanceChunk_includesLeadingWhitespaceAndNextVisibleToken() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: " world again"), + " world" + ) + } + + func test_nextAcceptanceChunk_returnsSingleTokenWhenNoLeadingWhitespace() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "world again"), + "world" + ) + } + + func test_nextAcceptanceChunk_returnsEmptyForEmptyTail() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: ""), "") + } + + func test_nextAcceptanceChunk_defaultsToAcceptingTrailingPunctuation() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?"), "you?") + } + + func test_nextAcceptanceChunk_keepsTrailingPunctuationWhenAutoAcceptEnabled() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?", autoAcceptTrailingPunctuation: true), + "you?" + ) + } + + func test_nextAcceptanceChunk_splitsTrailingPunctuationWhenAutoAcceptDisabled() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?", autoAcceptTrailingPunctuation: false), + "you" + ) + } + + func test_nextAcceptanceChunk_returnsLeftoverPunctuationAsItsOwnPart() { + // After "you" is accepted, the remaining tail is the bare punctuation, taken whole next. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "?", autoAcceptTrailingPunctuation: false), + "?" + ) + } + + func test_nextAcceptanceChunk_splitsMultipleTrailingMarksAsOnePart() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "you?!", autoAcceptTrailingPunctuation: false), + "you" + ) + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "?!", autoAcceptTrailingPunctuation: false), + "?!" + ) + } + + func test_nextAcceptanceChunk_preservesInternalPunctuationWhenSplitting() { + // Apostrophes and interior dots are not trailing, so the word stays whole. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "don't", autoAcceptTrailingPunctuation: false), + "don't" + ) + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A", autoAcceptTrailingPunctuation: false), + "U.S.A" + ) + } + + func test_nextAcceptanceChunk_splitsOnlyFinalPeriodAfterInteriorDots() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A.", autoAcceptTrailingPunctuation: false), + "U.S.A" + ) + } + + func test_nextAcceptanceChunk_keepsLeadingWhitespaceWhenSplittingPunctuation() { + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: " world!", autoAcceptTrailingPunctuation: false), + " world" + ) + } + + func test_nextAcceptanceChunk_splittingStopsAtFirstWhitespaceBoundary() { + // The first token has no trailing punctuation, so splitting leaves it whole and never + // reaches the punctuation on the following word. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptanceChunk(from: "hello world?", autoAcceptTrailingPunctuation: false), + "hello" + ) + } + + // MARK: - Space-less-script word acceptance + + func test_nextAcceptanceChunk_latinAcceptanceIsUnchangedBySpacelessBranch() { + // Regression guard: the space-less branch must never alter space-delimited acceptance. + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "hello world"), "hello") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "don't stop now"), "don't") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "U.S.A today"), "U.S.A") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "1.5 times"), "1.5") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "café René"), "café") + // A space-less script appearing later in the tail must not pull the first Latin token early. + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "world 你好"), "world") + } + + func test_nextAcceptanceChunk_segmentsChineseBelowWholeLength() { + // ICU word segmentation may be per-character or dictionary-based depending on the OS, so this + // asserts the robust property (accept one segment, not the whole run) rather than a pinned word. + let run = "你好世界" + let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) + XCTAssertFalse(chunk.isEmpty) + XCTAssertTrue(run.hasPrefix(chunk)) + XCTAssertLessThan(chunk.count, run.count, "a space-less Chinese run must segment, not accept the whole run") + } + + func test_nextAcceptanceChunk_segmentsJapaneseRunBelowWholeLength() { + let run = "今日はいい天気です" + let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) + XCTAssertFalse(chunk.isEmpty) + XCTAssertTrue(run.hasPrefix(chunk)) + XCTAssertLessThan(chunk.count, run.count, "a space-less Japanese run must segment, not accept whole") + } + + func test_nextAcceptanceChunk_segmentsThaiRunBelowWholeLength() { + let run = "สวัสดีครับ" + let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: run) + XCTAssertFalse(chunk.isEmpty) + XCTAssertTrue(run.hasPrefix(chunk)) + XCTAssertLessThan(chunk.count, run.count, "a space-less Thai run must segment, not accept whole") + } + + func test_nextAcceptanceChunk_chineseAcceptanceStaysWithinRunBeforeSpace() { + let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: "你好 world") + XCTAssertFalse(chunk.isEmpty) + XCTAssertTrue("你好".hasPrefix(chunk), "acceptance must stay within the CJK run and not cross the space") + XCTAssertFalse(chunk.contains(" ")) + } + + func test_nextAcceptanceChunk_keepsLeadingWhitespaceBeforeSpacelessWord() { + let chunk = SuggestionSessionReconciler.nextAcceptanceChunk(from: " 你好世界") + XCTAssertTrue(chunk.hasPrefix(" "), "leading whitespace is preserved before the segmented word") + let afterSpace = String(chunk.dropFirst()) + XCTAssertFalse(afterSpace.isEmpty) + XCTAssertTrue("你好世界".hasPrefix(afterSpace)) + XCTAssertLessThan(afterSpace.count, 4, "only the first segment is accepted, not the whole run") + } + + // MARK: - Phrase chunker +}