From 8c87f475c5b8e7329e7ffb83899b05415f883ee8 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:06:09 -0700 Subject: [PATCH] feat(context): make the live preview a real field the app completes in Replace the bespoke ghost-rendering NSTextView editor (which mirrored a SwiftUI binding and reconciled a ghost run inside its own storage, racing with and corrupting live keystrokes) with a plain native field, and let the production focus -> suggestion -> overlay pipeline drive it. FocusTracker lifts its 'never complete in our own UI' rule for this one element, matched by a known AX identifier, so the preview exercises the real engine end to end while every other settings surface stays blocked. The gate decision is a pure, unit-tested helper (SelfCaptureGate). --- Cotabby.xcodeproj/project.pbxproj | 24 +- .../Coordinators/SettingsCoordinator.swift | 8 - Cotabby/App/Core/CotabbyAppEnvironment.swift | 14 +- Cotabby/Models/FocusTrackingModel.swift | 2 + Cotabby/Services/Focus/FocusTracker.swift | 19 +- Cotabby/Support/AXHelper.swift | 7 + Cotabby/Support/SelfCaptureGate.swift | 34 ++ .../Components/ContextLivePreviewField.swift | 78 +++++ .../Components/InlineCompletionEditor.swift | 293 ------------------ .../UI/Settings/Panes/ContextPaneView.swift | 121 ++------ .../UI/Settings/Panes/LivePreviewModel.swift | 154 --------- .../UI/Settings/SettingsContainerView.swift | 12 +- CotabbyTests/LivePreviewModelTests.swift | 120 ------- CotabbyTests/SelfCaptureGateTests.swift | 79 +++++ 14 files changed, 264 insertions(+), 701 deletions(-) create mode 100644 Cotabby/Support/SelfCaptureGate.swift create mode 100644 Cotabby/UI/Settings/Components/ContextLivePreviewField.swift delete mode 100644 Cotabby/UI/Settings/Components/InlineCompletionEditor.swift delete mode 100644 Cotabby/UI/Settings/Panes/LivePreviewModel.swift delete mode 100644 CotabbyTests/LivePreviewModelTests.swift create mode 100644 CotabbyTests/SelfCaptureGateTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ef06c67c..08921f58 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -13,7 +13,6 @@ 0333B3CE8F189DD1BEC4AD26 /* MenuBarSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */; }; 0431AE1DBEE36C90C7F39C19 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; 046C133967B32BBF9205EBB1 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */; }; - 05B377C28966912EED8F4D66 /* InlineCompletionEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E728024C0C6D162FA82ACE3 /* InlineCompletionEditor.swift */; }; 078FDE669437D756678E9AB7 /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */; }; 07D046D406411ED85AC5758A /* InputMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */; }; 0A2DDD946654076675AC0FC6 /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */; }; @@ -64,6 +63,7 @@ 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 286B7022E2A2774275004447 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */; }; 2C6159231472A849F15BD0AE /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */; }; + 2CCF87FD35BF2C438EEA606D /* SelfCaptureGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */; }; 2D93881A6AE7DA50698600A3 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */; }; 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; @@ -192,7 +192,6 @@ 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; 93EBF0366891222B7DD6C38D /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */; }; - 953AE4D7AAE45BBBA5F789F5 /* LivePreviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D5455C46AD9416FEFF2DB6 /* LivePreviewModel.swift */; }; 959439B4785B996CE6D89944 /* EmojiUsageModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */; }; 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */; }; 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; @@ -222,7 +221,7 @@ AB5D37BA744546F14C5566E8 /* AppearancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */; }; AB9C9C001F97F9D14F8B192A /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4C4A7EAF886E0CC945BFEF /* TerminalAppDetector.swift */; }; AECC7289DA796B071B4FE3C0 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */; }; - AF377024AA1E276AEB184719 /* LivePreviewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCC8AFA7F1FFD40CE58C921 /* LivePreviewModelTests.swift */; }; + AF26E77871200BB1FAAEBE79 /* SelfCaptureGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.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 */; }; B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; @@ -304,6 +303,7 @@ F78F594F77C26C233377E71F /* KeyCodeLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */; }; F8E86FA4D6CEEBFA7FB55F8D /* KeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */; }; FAC7FFC78BEBF62D5B7A2EFB /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */; }; + FB0E2CE46002270A254E5FB3 /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */; }; FBEDA005ECD53CD645CD4C64 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3384FD33776960103D6E22A9 /* EmojiPickerView.swift */; }; FC255241A3B34A5717F09B36 /* InsertionStrategySelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960080A726E51198225147A /* InsertionStrategySelectorTests.swift */; }; FC6B0524B774F20C18BD6889 /* OnboardingTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */; }; @@ -457,7 +457,6 @@ 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextColorPreset.swift; sourceTree = ""; }; - 7E728024C0C6D162FA82ACE3 /* InlineCompletionEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCompletionEditor.swift; sourceTree = ""; }; 7F4C4A7EAF886E0CC945BFEF /* TerminalAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetector.swift; sourceTree = ""; }; 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMetricsStore.swift; sourceTree = ""; }; 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatter.swift; sourceTree = ""; }; @@ -577,6 +576,7 @@ D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryClassifier.swift; sourceTree = ""; }; D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolverTests.swift; sourceTree = ""; }; D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModePolicyTests.swift; sourceTree = ""; }; + D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGateTests.swift; sourceTree = ""; }; D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePosition.swift; sourceTree = ""; }; D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngineTests.swift; sourceTree = ""; }; @@ -592,6 +592,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformancePaneView.swift; sourceTree = ""; }; E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingDuplicationFilterTests.swift; sourceTree = ""; }; + E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextLivePreviewField.swift; sourceTree = ""; }; E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudgetTests.swift; sourceTree = ""; }; E27B962C66727776D00069DE /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; @@ -599,6 +600,7 @@ E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = ""; }; E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = ""; }; E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTracker.swift; sourceTree = ""; }; + E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabels.swift; sourceTree = ""; }; EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsPaneView.swift; sourceTree = ""; }; @@ -606,11 +608,9 @@ ED8672B87CEC72BE3978C6BB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolverTests.swift; sourceTree = ""; }; EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; - EFCC8AFA7F1FFD40CE58C921 /* LivePreviewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePreviewModelTests.swift; sourceTree = ""; }; EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; F050CE655081B840E361899E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; F308F6E274CC645E27CB651F /* OverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayController.swift; sourceTree = ""; }; - F4D5455C46AD9416FEFF2DB6 /* LivePreviewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePreviewModel.swift; sourceTree = ""; }; F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluator.swift; sourceTree = ""; }; FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizer.swift; sourceTree = ""; }; FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommender.swift; sourceTree = ""; }; @@ -715,7 +715,6 @@ FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */, 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */, D1123AB515110BD0CBA39490 /* HomePaneView.swift */, - F4D5455C46AD9416FEFF2DB6 /* LivePreviewModel.swift */, DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, @@ -810,7 +809,7 @@ isa = PBXGroup; children = ( E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */, - 7E728024C0C6D162FA82ACE3 /* InlineCompletionEditor.swift */, + E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */, 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */, 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */, ); @@ -929,7 +928,6 @@ 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */, 2960080A726E51198225147A /* InsertionStrategySelectorTests.swift */, 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */, - EFCC8AFA7F1FFD40CE58C921 /* LivePreviewModelTests.swift */, 0CA88BB29BC8727878C99E95 /* LlamaPromptCacheHintTrackerTests.swift */, AABCC3FD99B1824A81E665F3 /* LlamaSuggestionEngineCancellationTests.swift */, D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */, @@ -950,6 +948,7 @@ E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */, B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */, 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */, + D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */, 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, @@ -1124,6 +1123,7 @@ AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */, 6DC693E00430F46E41CB56E6 /* RequestID.swift */, 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */, + E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */, D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */, @@ -1295,6 +1295,7 @@ 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */, 429CE592897D8A952F2916C3 /* ConfidenceSuppressionPolicy.swift in Sources */, 8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */, + FB0E2CE46002270A254E5FB3 /* ContextLivePreviewField.swift in Sources */, 2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */, 210EEE9D094586286EDD89E8 /* ControlTokenMarkers.swift in Sources */, AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */, @@ -1356,7 +1357,6 @@ B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */, A87978083EBE1AC294377F7C /* HuggingFaceSearchService.swift in Sources */, FDBD858EDABA08FBBE0C7ED3 /* InlineCommandCoordinator.swift in Sources */, - 05B377C28966912EED8F4D66 /* InlineCompletionEditor.swift in Sources */, 15D4C75B6BA901E110AC929F /* InlinePreviewPanelController.swift in Sources */, 59CFE4B314842EDD6EDAC5C9 /* InlinePreviewView.swift in Sources */, 5F019E5A0679FFB52F129798 /* InputModels.swift in Sources */, @@ -1369,7 +1369,6 @@ 046C133967B32BBF9205EBB1 /* LLMIOFileHandler.swift in Sources */, 0A2DDD946654076675AC0FC6 /* LanguageCatalog.swift in Sources */, 51C069603DA16830868F1628 /* LanguageTagsEditor.swift in Sources */, - 953AE4D7AAE45BBBA5F789F5 /* LivePreviewModel.swift in Sources */, 66D9E37B12A9265D4733E72E /* LlamaRuntimeCore.swift in Sources */, 54BDF0D9C3DC7175555BD0F6 /* LlamaRuntimeManager.swift in Sources */, 4CAFD8F3444FEDC9ACAFF529 /* LlamaRuntimeModels.swift in Sources */, @@ -1417,6 +1416,7 @@ 0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */, DD7FA343F1C21C4569F6D181 /* ScreenshotContextGenerator.swift in Sources */, 9F6F88ED74ECA3E23A8E3CC0 /* SecureFieldDetector.swift in Sources */, + 2CCF87FD35BF2C438EEA606D /* SelfCaptureGate.swift in Sources */, 9ADFFF634912F638D079E1C7 /* SentenceBoundaryClassifier.swift in Sources */, 84A4CA05AF6885AE4FA4C13A /* SettingsAttentionEvaluator.swift in Sources */, 907A0BF56C3BB0CBAF2649AB /* SettingsCategory.swift in Sources */, @@ -1526,7 +1526,6 @@ 83EC3543DC45B1601F119BF9 /* InsertionSafetyGateTests.swift in Sources */, FC255241A3B34A5717F09B36 /* InsertionStrategySelectorTests.swift in Sources */, E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */, - AF377024AA1E276AEB184719 /* LivePreviewModelTests.swift in Sources */, E38801433B99E65BD7E45A0E /* LlamaPromptCacheHintTrackerTests.swift in Sources */, BE3CB85508055D159C35020A /* LlamaSuggestionEngineCancellationTests.swift in Sources */, 8429B116328C392DCA018D95 /* MacroEngineTests.swift in Sources */, @@ -1547,6 +1546,7 @@ 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */, 1B3FFCB9A979F49BF86EAAD4 /* ScreenshotContextGeneratorTests.swift in Sources */, 4FC52FB28AFC013F000D8FF9 /* SecureFieldDetectorTests.swift in Sources */, + AF26E77871200BB1FAAEBE79 /* SelfCaptureGateTests.swift in Sources */, 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 82b53056..96a8132e 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -19,8 +19,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let runtimeModel: RuntimeBootstrapModel private let modelDownloadManager: ModelDownloadManager private let huggingFaceSearchService: HuggingFaceSearchService - private let suggestionEngine: any SuggestionGenerating - private let configuration: SuggestionConfiguration private let performanceMetricsStore: PerformanceMetricsStore private let systemMetricsStore: SystemMetricsStore private let onShowWelcome: () -> Void @@ -37,8 +35,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { runtimeModel: RuntimeBootstrapModel, modelDownloadManager: ModelDownloadManager, huggingFaceSearchService: HuggingFaceSearchService, - suggestionEngine: any SuggestionGenerating, - configuration: SuggestionConfiguration, performanceMetricsStore: PerformanceMetricsStore, systemMetricsStore: SystemMetricsStore, onShowWelcome: @escaping () -> Void, @@ -52,8 +48,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.runtimeModel = runtimeModel self.modelDownloadManager = modelDownloadManager self.huggingFaceSearchService = huggingFaceSearchService - self.suggestionEngine = suggestionEngine - self.configuration = configuration self.performanceMetricsStore = performanceMetricsStore self.systemMetricsStore = systemMetricsStore self.onShowWelcome = onShowWelcome @@ -83,8 +77,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { huggingFaceSearchService: huggingFaceSearchService, performanceMetricsStore: performanceMetricsStore, systemMetricsStore: systemMetricsStore, - suggestionEngine: suggestionEngine, - configuration: configuration, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory ) diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 5d078037..7c6dce93 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -23,11 +23,6 @@ final class CotabbyAppEnvironment { let powerSourceMonitor: PowerSourceMonitor let clipboardContextProvider: ClipboardContextProvider let suggestionCoordinator: SuggestionCoordinator - /// Shared with the Advanced settings pane so the user can fire an ad-hoc generation against - /// the currently-selected engine and verify that Extended Context (and other prompt inputs) - /// are actually shaping the output. Reusing the live router means the playground produces the - /// same answer the autocomplete pipeline would, not a stand-in. - let suggestionEngine: any SuggestionGenerating let emojiPickerController: EmojiPickerController let macroController: MacroController let inlineCommandCoordinator: InlineCommandCoordinator @@ -76,6 +71,9 @@ final class CotabbyAppEnvironment { let focusModel = FocusTrackingModel( permissionProvider: { permissionManager.accessibilityGranted }, ignoredBundleIdentifier: Bundle.main.bundleIdentifier, + // The Context pane's live-preview field is the single sanctioned spot where Cotabby may + // complete inside its own UI; the focus tracker recognises it by this AX identifier. + selfCaptureAllowedElementIdentifier: ContextLivePreview.accessibilityIdentifier, isCaptureSuppressedForBundle: { bundleIdentifier in guard suggestionSettings.isGloballyEnabled else { return true } if let bundleIdentifier, @@ -113,9 +111,6 @@ final class CotabbyAppEnvironment { // Live CPU/RAM graph backing for the Performance pane. Holds no state until the pane asks it // to start sampling, so constructing it eagerly here costs nothing. let systemMetricsStore = SystemMetricsStore() - // Settings coordinator construction is deferred below until after `suggestionEngine` is - // built — the Advanced pane's "try it" playground needs the engine so it can fire ad-hoc - // generations using the same router the autocomplete pipeline does. let suggestionInserter = SuggestionInserter(suppressionController: suppressionController) let overlayController = OverlayController(suggestionSettings: suggestionSettings) let activationIndicatorController = ActivationIndicatorController() @@ -169,8 +164,6 @@ final class CotabbyAppEnvironment { runtimeModel: runtimeModel, modelDownloadManager: modelDownloadManager, huggingFaceSearchService: huggingFaceSearchService, - suggestionEngine: suggestionEngine, - configuration: configuration, performanceMetricsStore: performanceMetricsStore, systemMetricsStore: systemMetricsStore, onShowWelcome: { [weak welcomeCoordinator] in @@ -254,7 +247,6 @@ final class CotabbyAppEnvironment { self.powerSourceMonitor = powerSourceMonitor self.clipboardContextProvider = clipboardContextProvider self.suggestionCoordinator = suggestionCoordinator - self.suggestionEngine = suggestionEngine self.emojiPickerController = emojiPickerController self.macroController = macroController self.inlineCommandCoordinator = inlineCommandCoordinator diff --git a/Cotabby/Models/FocusTrackingModel.swift b/Cotabby/Models/FocusTrackingModel.swift index 684d1825..1c26566f 100644 --- a/Cotabby/Models/FocusTrackingModel.swift +++ b/Cotabby/Models/FocusTrackingModel.swift @@ -20,6 +20,7 @@ final class FocusTrackingModel: ObservableObject { init( permissionProvider: @escaping @MainActor () -> Bool, ignoredBundleIdentifier: String?, + selfCaptureAllowedElementIdentifier: String? = nil, isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false }, publishesPollingEvents: Bool = false ) { @@ -27,6 +28,7 @@ final class FocusTrackingModel: ObservableObject { tracker = FocusTracker( permissionProvider: permissionProvider, ignoredBundleIdentifier: ignoredBundleIdentifier, + selfCaptureAllowedElementIdentifier: selfCaptureAllowedElementIdentifier, isCaptureSuppressedForBundle: isCaptureSuppressedForBundle ) snapshot = tracker.snapshot diff --git a/Cotabby/Services/Focus/FocusTracker.swift b/Cotabby/Services/Focus/FocusTracker.swift index c9878657..4b1d1925 100644 --- a/Cotabby/Services/Focus/FocusTracker.swift +++ b/Cotabby/Services/Focus/FocusTracker.swift @@ -23,6 +23,11 @@ final class FocusTracker { private var pollInterval: TimeInterval private let permissionProvider: @MainActor () -> Bool private let ignoredBundleIdentifier: String? + /// AX identifier of the one element inside Cotabby's own UI that is allowed to be captured: the + /// Context pane's live-preview field. `nil` (the default) keeps the strict "never complete in our + /// own process" rule with no exception. Keyed on AX identity rather than bundle so every other + /// element in Cotabby's windows stays blocked. + private let selfCaptureAllowedElementIdentifier: String? /// Returns true when the focused app's bundle should NOT have its AX tree deep-walked. The /// gate runs after the cheap system-wide focused-element query but before the expensive /// candidate-elements walk in `FocusSnapshotResolver`. macOS popovers (Calendar's event-detail @@ -65,12 +70,14 @@ final class FocusTracker { pollInterval: TimeInterval = 0.08, permissionProvider: @escaping @MainActor () -> Bool, ignoredBundleIdentifier: String?, + selfCaptureAllowedElementIdentifier: String? = nil, isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false }, snapshotResolver: FocusSnapshotResolver? = nil ) { self.pollInterval = pollInterval self.permissionProvider = permissionProvider self.ignoredBundleIdentifier = ignoredBundleIdentifier + self.selfCaptureAllowedElementIdentifier = selfCaptureAllowedElementIdentifier self.isCaptureSuppressedForBundle = isCaptureSuppressedForBundle // Default resolver construction must happen inside the actor-isolated initializer body. // Swift evaluates default parameter expressions before entering the `@MainActor` context. @@ -222,7 +229,17 @@ final class FocusTracker { ) } - if application.bundleIdentifier == ignoredBundleIdentifier { + // Cotabby never completes inside its own UI, with one sanctioned exception: the Context pane's + // live-preview field, tagged with a known AX identifier so the user can exercise the real + // pipeline against their settings. Every other element in Cotabby's own windows (search field, + // Extended Context editor, menus) stays blocked, so this cannot leak completions into Settings. + // The identifier AX read is an autoclosure, so it runs only when Cotabby itself is focused. + if !SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: application.bundleIdentifier, + ignoredBundleIdentifier: ignoredBundleIdentifier, + focusedElementIdentifier: AXHelper.accessibilityIdentifier(of: focusedElement), + sanctionedElementIdentifier: selfCaptureAllowedElementIdentifier + ) { return inactiveCapture( applicationName: application.localizedName ?? "Cotabby", bundleIdentifier: application.bundleIdentifier, diff --git a/Cotabby/Support/AXHelper.swift b/Cotabby/Support/AXHelper.swift index b69eaa0f..773cdfb2 100644 --- a/Cotabby/Support/AXHelper.swift +++ b/Cotabby/Support/AXHelper.swift @@ -90,6 +90,13 @@ enum AXHelper { return nil } + /// Reads an element's AX identifier (what `NSView.setAccessibilityIdentifier` surfaces). There is + /// no public `kAX...` constant for it; the raw attribute name is "AXIdentifier". Used to recognise + /// Cotabby's own sanctioned live-preview field so the focus pipeline can complete in it. + static func accessibilityIdentifier(of element: AXUIElement) -> String? { + stringValue(for: "AXIdentifier" as CFString, on: element) + } + static func boolValue(for attribute: CFString, on element: AXUIElement) -> Bool? { guard let number = copyAttributeValue(attribute, on: element) as? NSNumber else { return nil diff --git a/Cotabby/Support/SelfCaptureGate.swift b/Cotabby/Support/SelfCaptureGate.swift new file mode 100644 index 00000000..b84f7271 --- /dev/null +++ b/Cotabby/Support/SelfCaptureGate.swift @@ -0,0 +1,34 @@ +import Foundation + +/// File overview: +/// The pure decision behind Cotabby's "never complete inside our own UI" rule and its single +/// sanctioned exception. It is kept free of Accessibility objects and timer state so the invariant +/// that matters most here can be unit-tested directly: completions must never leak into Cotabby's own +/// settings surfaces (search field, Extended Context editor, menus) except the one live-preview box. +/// +/// `FocusTracker` owns the AX reads; it hands the focused element's bundle and identifier to this type +/// and lets it answer yes/no. +enum SelfCaptureGate { + /// Whether the focus pipeline may capture the currently focused element. + /// + /// Apps other than Cotabby are always allowed: this rule only constrains capturing Cotabby's own + /// UI. When Cotabby itself is focused, capture is allowed only for the sanctioned element (the + /// Context pane's live-preview field, matched by AX identifier) and blocked for everything else. + /// + /// Fails closed: with no sanctioned identifier configured, or an element whose identifier cannot + /// be read, self-capture stays blocked. `focusedElementIdentifier` is an `@autoclosure` so the AX + /// read it usually wraps is skipped entirely for every other app (the common path, run on every + /// poll tick). + static func allowsCapture( + focusedBundleIdentifier: String?, + ignoredBundleIdentifier: String?, + focusedElementIdentifier: @autoclosure () -> String?, + sanctionedElementIdentifier: String? + ) -> Bool { + // Not our own process: untouched by this rule. + guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true } + // Our own process: allow only the one sanctioned element. + guard let sanctioned = sanctionedElementIdentifier else { return false } + return focusedElementIdentifier() == sanctioned + } +} diff --git a/Cotabby/UI/Settings/Components/ContextLivePreviewField.swift b/Cotabby/UI/Settings/Components/ContextLivePreviewField.swift new file mode 100644 index 00000000..4d152610 --- /dev/null +++ b/Cotabby/UI/Settings/Components/ContextLivePreviewField.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI + +/// File overview: +/// The Context pane's live-preview field, and the shared identity that lets the real pipeline drive it. +/// +/// Cotabby never completes inside its own UI (`FocusTracker` blocks capture whenever Cotabby is the +/// focused app, so completions can never appear in the settings search field, the Extended Context +/// editor, menus, and so on). The live preview is the single sanctioned exception: it is a real, +/// native editable text view, tagged with `ContextLivePreview.accessibilityIdentifier`, and +/// `FocusTracker` lifts its self-capture rule for that one element only. Typing in it is therefore a +/// genuine end-to-end exercise of the production focus -> suggestion -> overlay -> insertion path, the +/// same one every other app gets. +/// +/// Because the real overlay renders the gray suggestion at the caret and the real inserter commits it +/// on the accept key, this view renders nothing itself. That is the whole point of the redesign: the +/// previous in-app editor mirrored a SwiftUI binding into an `NSTextView` and reconciled a ghost run +/// inside the editable storage, and that reconciliation raced with live keystrokes and corrupted typed +/// text. Here the `NSTextView` owns its string outright and nothing reaches into it, so typing is fully +/// native. +enum ContextLivePreview { + /// AX identifier on the preview field. `FocusTracker` keys on this exact value to allow + /// self-capture for this element and nothing else in Cotabby's own windows. Wired into the focus + /// pipeline in `CotabbyAppEnvironment`. + static let accessibilityIdentifier = "com.cotabby.settings.context.live-preview" +} + +/// A plain multi-line `NSTextView` whose only special behavior is carrying the sanctioned AX +/// identifier. No ghost logic, no binding round-trip: the running app completes in it like any field. +struct ContextLivePreviewField: NSViewRepresentable { + private static let fontSize: CGFloat = 13 + private static let textInset = NSSize(width: 8, height: 8) + + func makeNSView(context: Context) -> NSScrollView { + let textView = NSTextView() + textView.font = .monospacedSystemFont(ofSize: Self.fontSize, weight: .regular) + textView.textColor = .labelColor + textView.isRichText = false + // Keep typing literal so the preview shows exactly what the model receives: no curly quotes, + // dash collapsing, autocorrect, or text replacement rewriting the user's characters. + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isContinuousSpellCheckingEnabled = false + textView.allowsUndo = true + textView.textContainerInset = Self.textInset + textView.drawsBackground = false + + // The identifier is the entire contract with `FocusTracker`: it is how the focus poller tells + // this sanctioned field apart from every other element in Cotabby's own windows. + textView.setAccessibilityIdentifier(ContextLivePreview.accessibilityIdentifier) + + // Standard incantation for a wrapping, vertically-growing text view inside a scroll view. + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + // Nothing to reconcile: the text view owns its content and the real pipeline owns the + // suggestion. Intentionally empty. + } +} diff --git a/Cotabby/UI/Settings/Components/InlineCompletionEditor.swift b/Cotabby/UI/Settings/Components/InlineCompletionEditor.swift deleted file mode 100644 index 19d28bc5..00000000 --- a/Cotabby/UI/Settings/Components/InlineCompletionEditor.swift +++ /dev/null @@ -1,293 +0,0 @@ -import AppKit -import SwiftUI - -/// File overview: -/// A focused multi-line text editor for the Context pane's live preview. It renders a model-provided -/// completion as gray "ghost" text immediately after the user's caret, exactly like Cotabby's real -/// overlay, and commits it on Tab. SwiftUI's `TextEditor` cannot place a styled continuation at the -/// caret, so this wraps `NSTextView` directly. -/// -/// Core invariant that keeps the AppKit bridge simple: -/// the ghost is only ever a trailing gray run at the very end of the storage, shown only when the -/// caret is an empty selection at end-of-text (the natural "continue my sentence" case). Because the -/// ghost is always the tail: -/// - stripping it before reporting the user's text is one range delete, -/// - a user edit (insert / delete / paste) at the caret never lands inside it, so we can strip it -/// uniformly in `textDidChange` instead of intercepting every edit entry point. -/// The two cases that *would* touch the ghost are handled explicitly: forward-delete dismisses it, -/// and a caret move off the boundary dismisses it. -/// -/// The ghost never enters the `text` binding. `text` is always the clean user string; `ghost` is a -/// display-only suffix owned by `LivePreviewModel`. -struct InlineCompletionEditor: NSViewRepresentable { - @Binding var text: String - /// Display-only completion suffix. Empty string means "no ghost". - let ghost: String - /// Commit the ghost into the user's text (Tab). The model moves `ghost` into `text`. - let onAccept: () -> Void - /// Discard the current ghost (Esc, forward-delete over it, or caret moved away from the end). - let onDismiss: () -> Void - - static let fontSize: CGFloat = 13 - private static let textInset = NSSize(width: 8, height: 8) - - func makeNSView(context: Context) -> NSScrollView { - let textView = InlineCompletionTextView() - textView.coordinator = context.coordinator - textView.delegate = context.coordinator - textView.font = .monospacedSystemFont(ofSize: Self.fontSize, weight: .regular) - textView.textColor = .labelColor - textView.typingAttributes = context.coordinator.userAttributes() - textView.isRichText = false - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isContinuousSpellCheckingEnabled = false - textView.allowsUndo = true - textView.textContainerInset = Self.textInset - textView.drawsBackground = false - - // Standard incantation for a wrapping, vertically-growing text view inside a scroll view. - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: 0, - height: CGFloat.greatestFiniteMagnitude - ) - - let scrollView = NSScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - - context.coordinator.textView = textView - textView.string = text - context.coordinator.reconcile(text: text, ghost: ghost) - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - // Keep the action closures current so Tab/Esc route to the latest model callbacks. - context.coordinator.parent = self - context.coordinator.reconcile(text: text, ghost: ghost) - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - /// Bridges `NSTextView` edits and the SwiftUI bindings, and owns the trailing-ghost bookkeeping. - @MainActor - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: InlineCompletionEditor - weak var textView: InlineCompletionTextView? - - /// UTF-16 length of the gray ghost run currently displayed at the tail, or 0 when none. - private var ghostLength = 0 - /// True while we mutate storage/selection ourselves, so delegate callbacks ignore our own - /// edits instead of treating them as user input. - private var isProgrammaticEdit = false - - init(parent: InlineCompletionEditor) { - self.parent = parent - } - - var hasGhost: Bool { ghostLength > 0 } - - /// The clean user string = full storage minus the trailing ghost run. - private var userText: String { - guard let textView else { return parent.text } - let full = textView.string as NSString - let userLength = max(0, full.length - ghostLength) - return full.substring(to: userLength) - } - - // MARK: Reconciliation (driven by updateNSView) - - /// Make the view reflect (`text`, `ghost`). User text is reconciled first so that on accept — - /// where `text` grows and `ghost` clears in the same update — the stale gray run is removed by - /// the ghost pass afterward rather than being double-counted. - func reconcile(text: String, ghost: String) { - if userText != text { - replaceUserText(text) - } - applyGhost(ghost) - } - - /// Replace just the user portion (used when the model changed `text`, e.g. after accept). - private func replaceUserText(_ newText: String) { - guard let textView, let storage = textView.textStorage else { return } - let full = textView.string as NSString - let userLength = max(0, full.length - ghostLength) - withProgrammaticEdit(on: textView) { - storage.replaceCharacters( - in: NSRange(location: 0, length: userLength), - with: NSAttributedString(string: newText, attributes: userAttributes()) - ) - let newUserLength = (newText as NSString).length - textView.setSelectedRange(NSRange(location: newUserLength, length: 0)) - } - } - - /// Make the trailing gray run equal `ghost`, inserting/removing as needed. No-ops when the - /// displayed ghost already matches. - private func applyGhost(_ ghost: String) { - guard let textView, let storage = textView.textStorage else { return } - let full = textView.string as NSString - let userLength = max(0, full.length - ghostLength) - let currentGhost = ghostLength > 0 ? full.substring(from: userLength) : "" - guard currentGhost != ghost else { return } - - withProgrammaticEdit(on: textView) { - if ghostLength > 0 { - storage.deleteCharacters(in: NSRange(location: userLength, length: ghostLength)) - } - if !ghost.isEmpty { - storage.insert( - NSAttributedString(string: ghost, attributes: ghostAttributes()), - at: userLength - ) - } - // Keep the caret at the user/ghost boundary so typing continues before the ghost. - textView.setSelectedRange(NSRange(location: userLength, length: 0)) - } - ghostLength = (ghost as NSString).length - } - - // MARK: NSTextViewDelegate - - func textDidChange(_ notification: Notification) { - guard !isProgrammaticEdit, let textView else { return } - // A user edit happened with the ghost (if any) still trailing. Strip the known tail so the - // binding only ever sees clean user text. Safe for insert/delete/paste because the caret - // sits at the user/ghost boundary, so the edit never lands inside the trailing run. - if ghostLength > 0 { - stripGhost() - } - let newText = textView.string - if parent.text != newText { - parent.text = newText - } - } - - func textViewDidChangeSelection(_ notification: Notification) { - guard !isProgrammaticEdit, ghostLength > 0, let textView else { return } - let userLength = max(0, (textView.string as NSString).length - ghostLength) - let selection = textView.selectedRange() - let caretAtBoundary = selection.length == 0 && selection.location == userLength - guard !caretAtBoundary else { return } - // The caret left the ghost boundary. This also fires transiently while the system applies - // a user insertion (the caret hops past the inserted char before `textDidChange` strips - // the ghost). Defer to the next main-actor tick and re-check so we only dismiss on a - // genuine caret move (a click or arrow key), not on typing — by then a real edit will have - // reset `ghostLength` to 0 and this no-ops. - Task { @MainActor [weak self] in - guard let self, self.ghostLength > 0, let textView = self.textView else { return } - let userLength = max(0, (textView.string as NSString).length - self.ghostLength) - let selection = textView.selectedRange() - let caretAtBoundary = selection.length == 0 && selection.location == userLength - if !caretAtBoundary { - self.parent.onDismiss() - } - } - } - - // MARK: Commands forwarded from the text view - - /// Tab. Commit the ghost when present; otherwise move focus rather than inserting a literal tab. - func handleTab() { - if ghostLength > 0 { - parent.onAccept() - } else { - textView?.window?.selectNextKeyView(nil) - } - } - - /// Esc. Drop the current ghost. - func handleEscape() { - if ghostLength > 0 { - parent.onDismiss() - } - } - - /// Forward-delete is the one edit that would land on the ghost's first character, so route it - /// to a dismiss instead of letting it eat the suggestion. Returns true when it consumed the key. - func handleForwardDeleteIfGhostPresent() -> Bool { - guard ghostLength > 0 else { return false } - parent.onDismiss() - return true - } - - private func stripGhost() { - guard let textView, let storage = textView.textStorage, ghostLength > 0 else { return } - let userLength = max(0, (textView.string as NSString).length - ghostLength) - withProgrammaticEdit(on: textView) { - storage.deleteCharacters(in: NSRange(location: userLength, length: ghostLength)) - } - ghostLength = 0 - } - - /// Runs `body` as a non-undoable programmatic mutation: delegate callbacks ignore it and the - /// user's undo stack stays operating on clean text only. - private func withProgrammaticEdit(on textView: NSTextView, _ body: () -> Void) { - isProgrammaticEdit = true - textView.undoManager?.disableUndoRegistration() - textView.textStorage?.beginEditing() - body() - textView.textStorage?.endEditing() - textView.undoManager?.enableUndoRegistration() - textView.typingAttributes = userAttributes() - isProgrammaticEdit = false - } - - func userAttributes() -> [NSAttributedString.Key: Any] { - [ - .font: NSFont.monospacedSystemFont(ofSize: InlineCompletionEditor.fontSize, weight: .regular), - .foregroundColor: NSColor.labelColor - ] - } - - private func ghostAttributes() -> [NSAttributedString.Key: Any] { - [ - .font: NSFont.monospacedSystemFont(ofSize: InlineCompletionEditor.fontSize, weight: .regular), - .foregroundColor: NSColor.inlineGhostText - ] - } - } -} - -/// `NSTextView` subclass that routes Tab / Esc / forward-delete to the inline-completion coordinator. -/// Kept as a thin shell: all state and bookkeeping live on the coordinator. -final class InlineCompletionTextView: NSTextView { - weak var coordinator: InlineCompletionEditor.Coordinator? - - override func insertTab(_ sender: Any?) { - coordinator?.handleTab() - } - - override func cancelOperation(_ sender: Any?) { - coordinator?.handleEscape() - } - - override func deleteForward(_ sender: Any?) { - if coordinator?.handleForwardDeleteIfGhostPresent() == true { - return - } - super.deleteForward(sender) - } -} - -private extension NSColor { - /// Matches `OverlayController`'s inline ghost gray so the sandbox preview reads like the real - /// product: lighter in dark mode, darker in light mode. - static let inlineGhostText = NSColor(name: nil) { appearance in - let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return isDark ? NSColor(white: 0.65, alpha: 1) : NSColor(white: 0.45, alpha: 1) - } -} diff --git a/Cotabby/UI/Settings/Panes/ContextPaneView.swift b/Cotabby/UI/Settings/Panes/ContextPaneView.swift index ca5df949..abe9b9b4 100644 --- a/Cotabby/UI/Settings/Panes/ContextPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ContextPaneView.swift @@ -1,18 +1,20 @@ import SwiftUI /// File overview: -/// "Context" detail pane of the Settings window. It leads with a live preview sandbox — an editor -/// that completes as you type, showing the suggestion as gray ghost text inline (Tab to accept, Esc -/// to dismiss), exactly like Cotabby behaves in a real app — so the user can see how their settings -/// and Extended Context shape real output. Below it sits the Extended Context editor (a free-form -/// blob folded into every prompt) with its cost warning co-located, then a short "how this is used" -/// note. +/// "Context" detail pane of the Settings window. It leads with a live preview: a real, native text +/// field that the running app completes in exactly as it does anywhere else. Typing in it drives the +/// production focus -> suggestion -> overlay pipeline end to end, so the gray suggestion, Tab to +/// accept, and Esc to dismiss are the real thing, not an in-app reimplementation. Below it sits the +/// Extended Context editor (a free-form blob folded into every prompt) with its cost warning +/// co-located, then a short "how this is used" note. /// -/// Why live preview leads (the redesign): -/// the pane previously buried a button-gated "Try it" box below the Extended Context editor, so -/// testing read as an afterthought and the click-to-run, static result did not feel like the product. -/// Putting the live sandbox first makes testing the primary action and Extended Context the -/// configuration that feeds it. +/// Why the field is real (the redesign): +/// the preview previously hand-rolled an `NSTextView` that mirrored a SwiftUI binding and rendered a +/// ghost run inside its own editable storage. That reconciliation raced with live keystrokes and +/// corrupted typed text, so the box felt unnatural to type in. The field is now plain and inert: +/// `FocusTracker` lifts its "never complete in our own UI" rule for this one element (keyed on +/// `ContextLivePreview.accessibilityIdentifier`), and the real overlay draws the suggestion at the +/// caret. The text view owns its string outright, so nothing competes with the user's typing. /// /// Why a dedicated pane (not Writing): the Writing pane carries name and language personalization. /// Extended Context is a different shape (long-form, free markdown, and noticeably more expensive on @@ -23,31 +25,10 @@ import SwiftUI /// can type a trailing space; `SuggestionRequestFactory` does the once-per-request trim instead. struct ContextPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel - let suggestionEngine: any SuggestionGenerating - let configuration: SuggestionConfiguration - - @StateObject private var livePreview: LivePreviewModel private static let previewEditorMinHeight: CGFloat = 132 private static let extendedContextEditorMinHeight: CGFloat = 220 - init( - suggestionSettings: SuggestionSettingsModel, - suggestionEngine: any SuggestionGenerating, - configuration: SuggestionConfiguration - ) { - self.suggestionSettings = suggestionSettings - self.suggestionEngine = suggestionEngine - self.configuration = configuration - _livePreview = StateObject( - wrappedValue: LivePreviewModel( - suggestionSettings: suggestionSettings, - suggestionEngine: suggestionEngine, - configuration: configuration - ) - ) - } - var body: some View { SettingsPaneScaffold { livePreviewSection @@ -67,36 +48,24 @@ struct ContextPaneView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - InlineCompletionEditor( - text: Binding( - get: { livePreview.userText }, - set: { livePreview.userDidEdit($0) } - ), - ghost: livePreview.ghost, - onAccept: { livePreview.acceptGhost() }, - onDismiss: { livePreview.dismissGhost() } - ) - .frame(minHeight: Self.previewEditorMinHeight) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color(nsColor: .separatorColor), lineWidth: 1) - ) - .accessibilityLabel("Live preview input") - - livePreviewStatusLine + ContextLivePreviewField() + .frame(minHeight: Self.previewEditorMinHeight) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityLabel("Live preview input") - if let error = livePreview.lastError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.secondary) - .labelStyle(.titleAndIcon) - .fixedSize(horizontal: false, vertical: true) - } + // The active engine, so the user knows which backend they're exercising. The live + // suggestion, latency, and accept cues come from the real overlay, not this pane. + Text(suggestionSettings.snapshot.selectedEngine.displayLabel) + .font(.caption) + .foregroundStyle(.secondary) Text("Nothing here is saved or shared; it only exercises the on-device model.") .font(.caption2) @@ -106,36 +75,6 @@ struct ContextPaneView: View { } } - /// Left: a spinner while generating plus the active engine. Right: a Tab hint while a suggestion - /// is showing, and the last generation's latency. Mirrors the cues the real overlay gives. - private var livePreviewStatusLine: some View { - HStack(spacing: 8) { - if livePreview.isGenerating { - ProgressView() - .controlSize(.small) - } - Text(livePreview.engineLabel) - .font(.caption) - .foregroundStyle(.secondary) - - Spacer(minLength: 0) - - if livePreview.hasGhost { - Label("Tab to accept", systemImage: "arrow.right.to.line") - .font(.caption) - .foregroundStyle(.secondary) - .labelStyle(.titleAndIcon) - } - - if let latency = livePreview.lastLatencyMilliseconds { - Text("\(latency) ms") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - } - // MARK: - Extended Context private var extendedContextSection: some View { diff --git a/Cotabby/UI/Settings/Panes/LivePreviewModel.swift b/Cotabby/UI/Settings/Panes/LivePreviewModel.swift deleted file mode 100644 index 68a00336..00000000 --- a/Cotabby/UI/Settings/Panes/LivePreviewModel.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Combine -import CoreGraphics -import Foundation - -/// File overview: -/// View model for the Context pane's live preview sandbox. Owns the user's typed text, the current -/// ghost suggestion, and the debounced generation that produces it. Unlike the old one-shot "Try it" -/// playground (a button that ran a single completion), this regenerates as the user pauses typing and -/// surfaces the result as inline ghost text, so the sandbox behaves like Cotabby does in a real app. -/// -/// Pane-private on purpose: it shapes one section of one settings pane, so it lives next to that pane -/// rather than in a shared layer where it would invite reuse it doesn't need. -/// -/// Generation reuses the production path end to end: `SuggestionRequestFactory.buildRequest` builds -/// the same request the live coordinator builds, and `SuggestionWorkController` provides the same -/// debounce + stale-result guarding. Only the trigger (typing pauses instead of focus events) and the -/// synthetic focus context are sandbox-specific. -@MainActor -final class LivePreviewModel: ObservableObject { - /// The user's authoritative text. Bound to the editor; never contains the ghost. - @Published var userText: String = "" - /// The current completion suffix shown as gray ghost text, or "" when none. - @Published private(set) var ghost: String = "" - @Published private(set) var isGenerating = false - @Published private(set) var lastLatencyMilliseconds: Int? - @Published private(set) var lastError: String? - - private let suggestionSettings: SuggestionSettingsModel - private let suggestionEngine: any SuggestionGenerating - private let configuration: SuggestionConfiguration - private let workController = SuggestionWorkController() - private let debounceMilliseconds: Int - - /// Debounce window for the sandbox. Deliberately longer than the live pipeline's default so we - /// don't fire a generation on every keystroke while the user is still typing in the box, while - /// still feeling responsive once they pause. `nonisolated` so it can serve as the init's default - /// argument (evaluated outside the main actor). - nonisolated static let defaultDebounceMilliseconds = 300 - - init( - suggestionSettings: SuggestionSettingsModel, - suggestionEngine: any SuggestionGenerating, - configuration: SuggestionConfiguration, - debounceMilliseconds: Int = LivePreviewModel.defaultDebounceMilliseconds - ) { - self.suggestionSettings = suggestionSettings - self.suggestionEngine = suggestionEngine - self.configuration = configuration - self.debounceMilliseconds = debounceMilliseconds - } - - /// Human-readable name of the engine that will service the next generation, for the status line. - var engineLabel: String { - suggestionSettings.snapshot.selectedEngine.displayLabel - } - - var hasGhost: Bool { !ghost.isEmpty } - - /// Called by the editor whenever the user edits the text. Clears the now-stale ghost and schedules - /// a fresh debounced generation. - func userDidEdit(_ newText: String) { - userText = newText - clearGhost() - scheduleGeneration() - } - - /// Commit the ghost into the user's text (Tab), then keep the flow going by generating the next - /// continuation from the grown text. - func acceptGhost() { - guard !ghost.isEmpty else { return } - userText += ghost - clearGhost() - scheduleGeneration() - } - - /// Drop the current ghost without committing it (Esc / caret moved away) and stop any pending work. - func dismissGhost() { - clearGhost() - isGenerating = false - workController.cancelAll() - } - - private func clearGhost() { - if !ghost.isEmpty { ghost = "" } - } - - private func scheduleGeneration() { - let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - isGenerating = false - workController.cancelAll() - return - } - isGenerating = true - lastError = nil - workController.replaceDebouncedWork(delayMilliseconds: debounceMilliseconds) { [weak self] workID in - await self?.generate(workID: workID) - } - } - - private func generate(workID: UInt64) async { - let prefixText = userText - let build = SuggestionRequestFactory.buildRequest( - context: Self.makeSyntheticContext(prefixText: prefixText), - settings: suggestionSettings.snapshot, - configuration: configuration - ) - do { - let result = try await suggestionEngine.generateSuggestion(for: build.request) - // Belt-and-suspenders alongside task cancellation: ignore a result whose work was already - // superseded by a newer keystroke (relevant if an engine ever ignores cancellation). - guard workController.isCurrent(workID) else { return } - ghost = result.text - lastLatencyMilliseconds = Int((result.latency * 1000).rounded()) - lastError = nil - isGenerating = false - } catch is CancellationError { - // Superseded by a newer keystroke; whoever owns the current work now owns the UI state. - return - } catch { - guard workController.isCurrent(workID) else { return } - lastError = error.localizedDescription - ghost = "" - lastLatencyMilliseconds = nil - isGenerating = false - } - } - - /// Builds a `FocusedInputContext` from the user's test text with the caret at the end. The values - /// are intentionally generic — the sandbox is a prompt-shape demo, not an attempt to mimic a - /// specific host app's accessibility surface — and the bundle id does not match a real app so - /// per-app tone hints fall through to defaults. - private static func makeSyntheticContext(prefixText: String) -> FocusedInputContext { - let snapshot = FocusedInputSnapshot( - applicationName: "Cotabby Playground", - bundleIdentifier: "com.cotabby.context.playground", - processIdentifier: 0, - elementIdentifier: "playground-field", - role: "AXTextArea", - subrole: nil, - caretRect: CGRect(x: 0, y: 0, width: 2, height: 18), - inputFrameRect: CGRect(x: 0, y: 0, width: 320, height: 96), - caretSource: "playground", - caretQuality: .exact, - observedCharWidth: nil, - precedingText: prefixText, - trailingText: "", - selection: NSRange(location: (prefixText as NSString).length, length: 0), - isSecure: false, - focusChangeSequence: 0 - ) - return FocusedInputContext(snapshot: snapshot, generation: 0) - } -} diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index f83ad377..9300aacc 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -23,12 +23,6 @@ struct SettingsContainerView: View { @ObservedObject var performanceMetricsStore: PerformanceMetricsStore @ObservedObject var systemMetricsStore: SystemMetricsStore - /// Live router used by the Context pane's "try it" playground so users can see the effect of - /// Extended Context (and other prompt inputs) without leaving Settings. Threaded through the - /// container rather than constructed locally so the playground reuses the same router the - /// autocomplete pipeline uses. - let suggestionEngine: any SuggestionGenerating - let configuration: SuggestionConfiguration let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void @@ -122,11 +116,7 @@ struct SettingsContainerView: View { case .writing: WritingPaneView(suggestionSettings: suggestionSettings) case .context: - ContextPaneView( - suggestionSettings: suggestionSettings, - suggestionEngine: suggestionEngine, - configuration: configuration - ) + ContextPaneView(suggestionSettings: suggestionSettings) case .shortcuts: ShortcutsPaneView(suggestionSettings: suggestionSettings) case .apps: diff --git a/CotabbyTests/LivePreviewModelTests.swift b/CotabbyTests/LivePreviewModelTests.swift deleted file mode 100644 index 592c7dfd..00000000 --- a/CotabbyTests/LivePreviewModelTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -import XCTest -@testable import Cotabby - -/// Tests for `LivePreviewModel`, the Context pane's live preview sandbox state machine. -/// -/// These lock down the user-visible behavior independent of any real engine: a completed generation -/// surfaces as ghost text plus a latency, Tab commits the ghost into the user's text, Esc drops it, -/// and an engine error surfaces without leaving a stale ghost. The model is driven with a zero -/// debounce so the debounced generation runs on the next tick instead of after the production delay. -@MainActor -final class LivePreviewModelTests: XCTestCase { - - func test_generation_populatesGhostAndLatency() async throws { - let model = makeModel(engine: StubEngine(behavior: .success("world"))) - - model.userDidEdit("Hello ") - try await waitUntil { model.hasGhost } - - XCTAssertEqual(model.ghost, "world") - XCTAssertFalse(model.isGenerating) - XCTAssertEqual(model.lastLatencyMilliseconds, 50) - XCTAssertNil(model.lastError) - } - - func test_acceptGhost_commitsGhostIntoUserText() async throws { - let model = makeModel(engine: StubEngine(behavior: .success("world"))) - - model.userDidEdit("Hello ") - try await waitUntil { model.hasGhost } - - model.acceptGhost() - - XCTAssertEqual(model.userText, "Hello world") - XCTAssertTrue(model.ghost.isEmpty) - } - - func test_dismissGhost_clearsGhost() async throws { - let model = makeModel(engine: StubEngine(behavior: .success("world"))) - - model.userDidEdit("Hello ") - try await waitUntil { model.hasGhost } - - model.dismissGhost() - - XCTAssertTrue(model.ghost.isEmpty) - XCTAssertFalse(model.isGenerating) - } - - func test_engineError_surfacesAndLeavesNoGhost() async throws { - let model = makeModel(engine: StubEngine(behavior: .failure(StubEngineError.boom))) - - model.userDidEdit("Hello ") - try await waitUntil { model.lastError != nil } - - XCTAssertTrue(model.ghost.isEmpty) - XCTAssertFalse(model.isGenerating) - } - - // MARK: - Helpers - - private func makeModel(engine: any SuggestionGenerating) -> LivePreviewModel { - let suiteName = "cotabby.test.livePreview.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - let settings = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) - return LivePreviewModel( - suggestionSettings: settings, - suggestionEngine: engine, - configuration: .standard, - debounceMilliseconds: 0 - ) - } - - /// Polls a main-actor condition until true or a timeout, sleeping between checks so the model's - /// debounced generation task gets to run on the main actor. - private func waitUntil( - timeout: TimeInterval = 2, - _ condition: () -> Bool - ) async throws { - let deadline = Date().addingTimeInterval(timeout) - while !condition() { - if Date() > deadline { - XCTFail("Condition not met within \(timeout)s") - return - } - try await Task.sleep(nanoseconds: 5_000_000) - } - } -} - -private enum StubEngineError: Error { - case boom -} - -/// Minimal `SuggestionGenerating` stub: returns a canned result or throws. Local to this file so it -/// does not collide with the differently-shaped stubs in the coordinator/router tests. -@MainActor -private final class StubEngine: SuggestionGenerating { - enum Behavior { - case success(String) - case failure(Error) - } - - let behavior: Behavior - - init(behavior: Behavior) { - self.behavior = behavior - } - - func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { - switch behavior { - case .success(let text): - return SuggestionResult(generation: request.generation, rawText: text, text: text, latency: 0.05) - case .failure(let error): - throw error - } - } - - func resetCachedGenerationContext() async {} -} diff --git a/CotabbyTests/SelfCaptureGateTests.swift b/CotabbyTests/SelfCaptureGateTests.swift new file mode 100644 index 00000000..7003995a --- /dev/null +++ b/CotabbyTests/SelfCaptureGateTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import Cotabby + +/// Tests for `SelfCaptureGate`, the rule that keeps Cotabby from completing inside its own UI while +/// allowing the one sanctioned exception (the Context pane's live-preview field). This is the safety +/// boundary the live-preview feature rests on, so it is pinned directly: other settings fields must +/// never become completion targets. +final class SelfCaptureGateTests: XCTestCase { + private let selfBundle = "com.cotabby.app" + private let previewIdentifier = "com.cotabby.settings.context.live-preview" + + func test_otherApp_isAlwaysAllowed() { + XCTAssertTrue(SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: "com.apple.TextEdit", + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: nil, + sanctionedElementIdentifier: previewIdentifier + )) + } + + /// The element identifier (an AX read in production) must not be evaluated for other apps, which + /// is the common path run on every poll tick. + func test_otherApp_doesNotEvaluateElementIdentifier() { + let probe = EvaluationProbe() + _ = SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: "com.apple.TextEdit", + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: probe.read(), + sanctionedElementIdentifier: previewIdentifier + ) + XCTAssertEqual(probe.count, 0) + } + + func test_self_previewField_isAllowed() { + XCTAssertTrue(SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: selfBundle, + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: previewIdentifier, + sanctionedElementIdentifier: previewIdentifier + )) + } + + func test_self_otherField_isBlocked() { + XCTAssertFalse(SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: selfBundle, + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: "com.cotabby.settings.search", + sanctionedElementIdentifier: previewIdentifier + )) + } + + func test_self_unreadableIdentifier_isBlocked() { + XCTAssertFalse(SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: selfBundle, + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: nil, + sanctionedElementIdentifier: previewIdentifier + )) + } + + func test_noSanctionedIdentifier_blocksAllSelfCapture() { + XCTAssertFalse(SelfCaptureGate.allowsCapture( + focusedBundleIdentifier: selfBundle, + ignoredBundleIdentifier: selfBundle, + focusedElementIdentifier: previewIdentifier, + sanctionedElementIdentifier: nil + )) + } + + /// Counts how many times its `read()` result is actually evaluated, to prove the autoclosure stays + /// lazy for the non-self path. + private final class EvaluationProbe { + private(set) var count = 0 + func read() -> String? { + count += 1 + return nil + } + } +}