diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index d2248b8d..0237c467 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 000EBFCBA8CE49537690613B /* SymSpellCorrectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C850141146422A132B2B3516 /* SymSpellCorrectorTests.swift */; }; + 010BE97D4679BDD23C2807A5 /* TerminalCommandIntentPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55D54FE82CEB8DE27E2E1839 /* TerminalCommandIntentPolicy.swift */; }; 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */; }; 0187EAA1D37B92DD5B264016 /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D00A031C0D9CF2A7A2330D9 /* PermissionDragSourceView.swift */; }; 021197C88AF19DEA715C849B /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; @@ -15,6 +16,7 @@ 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; 02FE7E7D0CC26AB67307D6D3 /* ru.txt in Resources */ = {isa = PBXBuildFile; fileRef = D8C2663427BC5219EA415D00 /* ru.txt */; }; 0333B3CE8F189DD1BEC4AD26 /* MenuBarSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */; }; + 042C13DE7F25CF9927C14DD4 /* TerminalIntegrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A22DF3811079416889294DB /* TerminalIntegrationService.swift */; }; 0431AE1DBEE36C90C7F39C19 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; 046C133967B32BBF9205EBB1 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */; }; 0479478CBE8D5387119C3E35 /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */; }; @@ -36,6 +38,7 @@ 08A76C831813F9D5CEC578E0 /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; 0933A62F9B9AECEEA95BBA29 /* SuggestionCoordinatorInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E25C90F99356EB5E249225 /* SuggestionCoordinatorInputTests.swift */; }; 094DE1CA4A821D722F173F9D /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; + 099485BFE060EB81A299E2E4 /* cotabby.fish in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9B5337F491E20304196669AC /* cotabby.fish */; }; 09FD133AFEFD8C08E7A9969D /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */; }; 0A08A88B773845EF9A27DF0F /* SymSpellCorrector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */; }; 0A15FAD03A93D57372D267E0 /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; @@ -100,6 +103,7 @@ 1DF03DC12D45C22753F683BC /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */; }; 1DF1A9DFF50E9219875E24FF /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */; }; 1E0796CADFB4F561EC94602A /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; + 1E0DE04F81FB92DD77043CC9 /* cotabby.bash in CopyFiles */ = {isa = PBXBuildFile; fileRef = B0520F0205BFEAC9C5FDDD3E /* cotabby.bash */; }; 1EB90D3D8A5BA028B86E4D9F /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; 1F714771EE793A5AF92F1E2B /* ModelDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51020F8CD58338BD643FBF63 /* ModelDownloadManager.swift */; }; 1F8CC88AFFE67C08944CF506 /* WindowScreenshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */; }; @@ -109,6 +113,7 @@ 2160F8CC328D511EDA32301E /* es.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0B6816DF5D33863F966240B4 /* es.txt */; }; 2197B68F1E4D0C3497DAC061 /* LlamaSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */; }; 227D70C89AE20DD5A3AC7D8B /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D00A031C0D9CF2A7A2330D9 /* PermissionDragSourceView.swift */; }; + 22A86A30CCF5A0A335FA7CAB /* TerminalCommandIntentPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55D54FE82CEB8DE27E2E1839 /* TerminalCommandIntentPolicy.swift */; }; 22DF59FD6F3B83B092A6F5ED /* WordCountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */; }; 2314C82FAAA81EB58BFE204D /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */; }; 231905D5C463FA994CACFA77 /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; @@ -141,8 +146,10 @@ 2B1F6B9BD4DEBD7DA4DD9855 /* TextLayoutCaretEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */; }; 2BE029A192E82E795490DC7F /* BrowserDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8025E4A296845FC53E660D /* BrowserDomain.swift */; }; 2C6159231472A849F15BD0AE /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */; }; + 2C6D811DA33B73E75E70CDEB /* TerminalWindowScreenshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44CB467BB81B6A22D1F321EE /* TerminalWindowScreenshotService.swift */; }; 2CCF87FD35BF2C438EEA606D /* SelfCaptureGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */; }; 2CDF880348D439708BD4F792 /* ru.txt in Resources */ = {isa = PBXBuildFile; fileRef = D8C2663427BC5219EA415D00 /* ru.txt */; }; + 2D303F5BDB40B173F9DC44E6 /* TerminalIntegrationPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4F0307BEE22C10F984AB70 /* TerminalIntegrationPaths.swift */; }; 2D32B63CB58FBC5BC2EA7125 /* PopupChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565AB9A1265315C500FE5BDF /* PopupChrome.swift */; }; 2D93881A6AE7DA50698600A3 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */; }; 2DA8DFFE8DF9B6AD71E28417 /* PopupChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565AB9A1265315C500FE5BDF /* PopupChrome.swift */; }; @@ -164,6 +171,7 @@ 314AD8F0FD781CB6DDE4603C /* GhostFontMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6439213F2A273702A6E26B /* GhostFontMetrics.swift */; }; 31515DDD173535C4AC777853 /* MirrorOverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54150A507B03221F137D539B /* MirrorOverlayLayout.swift */; }; 317883210D1D1D5CD654E562 /* ModelFileValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4E5869D103865486AAAEEC /* ModelFileValidator.swift */; }; + 3199BF69F9C6A51DB89803AA /* TerminalWindowScreenshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44CB467BB81B6A22D1F321EE /* TerminalWindowScreenshotService.swift */; }; 31DCCE980B6708401256D4D1 /* FoundationModelDriftEvalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49F3B597374208594861B9B /* FoundationModelDriftEvalTests.swift */; }; 323500F336AF70C520926383 /* MacroReferenceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */; }; 32A2915FAE21CD9CE818A9D9 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; @@ -193,6 +201,9 @@ 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4696A84D17890B154533A08F /* PromptPolicyTests.swift */; }; 3D56E9B3AA378400E2C081E3 /* LlamaEvalScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D4C1EF008B9DFA753D561D3 /* LlamaEvalScoringTests.swift */; }; 3D82280EFF7F7E9F3FFF45ED /* LlamaEvalScoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906011A6C9D66EEBAF3B5CC0 /* LlamaEvalScoring.swift */; }; + 3E19521F68F376B336E871BB /* TerminalPromptAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460F21DD131DB683C688B13C /* TerminalPromptAnchor.swift */; }; + 3E19BC087F0D5C54B150C75C /* TerminalCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C99268965A1FAF025BD0E0F /* TerminalCompletionPromptRenderer.swift */; }; + 3E1D7831517F123964CA9D5B /* TuiSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67585553297E95E55BF210ED /* TuiSessionDetector.swift */; }; 3E1DBB2ABCB2404B59173534 /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; 3E28FFB8F7D91D3B39968E73 /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; }; 3E6BD683DA505C9737463890 /* SettingsNavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */; }; @@ -204,6 +215,7 @@ 3FCEF50FDD9EE01AE3711083 /* AXTreeDumpWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27492B04B627DA53BDAD938 /* AXTreeDumpWriter.swift */; }; 3FF6B7DE34A01C4AB7FA54E3 /* MacroTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C201A65A6B040F90C528A3B /* MacroTriggerStateMachine.swift */; }; 400E1A5145FC8C5BA2FAED0A /* DeepGeometryWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */; }; + 403AD55C84EAA9C1B8A36A6C /* TerminalAwareFocusModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A312D6EC80D6ABA9BDF098C /* TerminalAwareFocusModel.swift */; }; 4086A1B07488C4D3D43D86C9 /* KeyboardInputSourceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534F1297DEF3547D0DE56FB2 /* KeyboardInputSourceMonitor.swift */; }; 4134ADBE464D00BB748BD9AE /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 418055EF945C17BAEFAB89ED /* CaretRunPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */; }; @@ -255,6 +267,7 @@ 50AABC3B54A9CF2879232276 /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */; }; 50E1247BE6A952AB1B12C444 /* PowerSourceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */; }; 513066CA00F0E9A5E7358A4E /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; + 513A2637FC41A59B2DC3E66B /* TerminalAwareFocusModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A312D6EC80D6ABA9BDF098C /* TerminalAwareFocusModel.swift */; }; 51C069603DA16830868F1628 /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */; }; 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 52F3962FA9F424576D7DB5B8 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; @@ -310,6 +323,7 @@ 614F45951D2BD56406E1F95E /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764EE0693994E2E126E7FC77 /* SuggestionFadeInPolicy.swift */; }; 61635150B8004F6CB2FACE65 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B0830FBE4F2E239F670DBA /* AppSurfaceClassifier.swift */; }; 61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; + 628D77202079BF5B7DFD5EE7 /* TuiContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F39E64E90E2FAD68BEBDFB /* TuiContextCoordinator.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 62FADA407797998742502DD9 /* CaretLinePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA827D6A2A54DF4BAD56405 /* CaretLinePositionTests.swift */; }; 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */; }; @@ -335,6 +349,8 @@ 6A4E62EC9B7B970695F87136 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; 6A8454A989104AE150308BCF /* it-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2D8AA55C2B730110E8598F91 /* it-100k.txt */; }; 6AE0B46FB52D189D94E1F79A /* WordCountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */; }; + 6B7AF50F895BC735896B9BAB /* TerminalIntegrationResourceInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E107A7AF067CC58C3E3D6D8 /* TerminalIntegrationResourceInstaller.swift */; }; + 6BCDA2747374467630233EF5 /* TerminalIntegrationResourceInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E107A7AF067CC58C3E3D6D8 /* TerminalIntegrationResourceInstaller.swift */; }; 6BE0C8F9D054A2C0D9018001 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */; }; 6C59B369AAC6948C53E41654 /* DebouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB23CDF7CAA1DEAD606B46B3 /* DebouncePolicyTests.swift */; }; 6CBEF02FCDFCF406E378C27C /* SuggestionInteractionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */; }; @@ -349,7 +365,9 @@ 7179FB0EC6411166CCD79F6B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; 7291020840F2164EBB764FAF /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; 7324B18578C646B1ADFF0C3F /* InsertedTextAdvanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */; }; + 73408AA20D4EB52B6CC9DB38 /* TerminalIntegrationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DCEB58889CA5C05D96E062 /* TerminalIntegrationModelTests.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; + 73704AE903CDD0B7800C89B6 /* TerminalFocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C9C6AE4DAC991702B54DB /* TerminalFocusModels.swift */; }; 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 746BC62993602F78147FF8B0 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; @@ -360,6 +378,7 @@ 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; 773808D3D88440F0836D0072 /* FileLogHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE98A6C28731BF5C8D434543 /* FileLogHandlerTests.swift */; }; 77823BCFB29F63615C514927 /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; + 77A1B96E29D83C79AD908D56 /* cotabby.zsh in CopyFiles */ = {isa = PBXBuildFile; fileRef = 6C5C0715FC1B971341BE4C57 /* cotabby.zsh */; }; 77B57484667F71F7EFA380D1 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 783BEC91DBC86AF75CEDB269 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; @@ -385,6 +404,7 @@ 7E99F5676A1D1DF7EA7D7702 /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */; }; 7EB2CB1000EA2407A2F59B4E /* SuggestionPauseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2FDC5884B07B19B9B4917A6 /* SuggestionPauseModels.swift */; }; + 7EBDA2B2663D380DD509DE4D /* TuiFocusAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979E5C579D257630B50F45C /* TuiFocusAdapter.swift */; }; 7EEE6AEBFBD419FFE7C544BA /* SuggestionSettingsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93563FDA25DFC0038E5F887 /* SuggestionSettingsData.swift */; }; 7FC103944F4EF39DB965F469 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = 88921938DC814625ED57D605 /* InMemoryLogging */; }; 814E348C663B697537594F0C /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */; }; @@ -399,6 +419,7 @@ 8429B116328C392DCA018D95 /* MacroEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */; }; 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */; }; 84A4CA05AF6885AE4FA4C13A /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */; }; + 850B02C1791A4E9BF87CCC3E /* TerminalGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4A45E115C85C714D8A91AC /* TerminalGeometryResolver.swift */; }; 856082F4732206A3761816DC /* SystemMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */; }; 862146ABDADC022A3BE74E00 /* CurrencyEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0537986794554F5FABE6EFF3 /* CurrencyEvaluator.swift */; }; 864EC22F38FB832BB96FF58B /* SettingsQuickLinkCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */; }; @@ -442,20 +463,25 @@ 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; 96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */; }; 96ECE89BD96CF93CC159B437 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */; }; + 96F228C25A85CF2822EC01F2 /* TerminalIntegrationPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4F0307BEE22C10F984AB70 /* TerminalIntegrationPaths.swift */; }; 9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; 972755DA269C9A5C34D64FBC /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */; }; + 9730D96D5776DFF8C5DC5F32 /* TerminalIntegrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A22DF3811079416889294DB /* TerminalIntegrationService.swift */; }; 97ECEF5AA17B26FA6F187461 /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 982BA68A53CEE0F45D41F3D3 /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */; }; 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */; }; 9938DE59D9E05BC51A5BA5B8 /* SuggestionDebugLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */; }; 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; + 99C354D2C57B57553FD40E7D /* ProcessTreeInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5353E2C9A1A83672FF8458 /* ProcessTreeInspector.swift */; }; 9A419D0704C95920CB71D3B1 /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */; }; 9A82FB431A61719F275623B8 /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; 9ADFFF634912F638D079E1C7 /* SentenceBoundaryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */; }; 9B0CE2B00695EC13A6E6CCF3 /* CurrencyEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0537986794554F5FABE6EFF3 /* CurrencyEvaluator.swift */; }; + 9B9A1326D35113E81C853A42 /* TuiFocusAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979E5C579D257630B50F45C /* TuiFocusAdapter.swift */; }; 9BA9E52F33F00E65692EE6CD /* he-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7C9BB65FA5FC42B89766B037 /* he-100k.txt */; }; 9C5B1ED23126B60E3BBF3A5B /* KeycapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB429709A7FA9D63B0D7B133 /* KeycapView.swift */; }; + 9C98C1CE04422343F371C948 /* ShellPromptGeometryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861CD3419995CFE17DD85D93 /* ShellPromptGeometryCoordinator.swift */; }; 9CEBD6AF4405F1BBE0E3D16C /* MidWordContinuationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */; }; 9D0F4829D11BCD4DB1290410 /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */; }; 9E031B67A275BB3E049EFC2F /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; @@ -484,6 +510,7 @@ A7A1B9BE242959B3EE396D27 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 7B0278A63FEEE8DEDB6C50DB /* LaunchAtLogin */; }; A81E3481B0067E9D6F9D1325 /* ScreenTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C34A98E5B05DB3F4FE81E70 /* ScreenTextExtractorTests.swift */; }; A87978083EBE1AC294377F7C /* HuggingFaceSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F426127917FCB1096134732 /* HuggingFaceSearchService.swift */; }; + A89810FF966941669F0FE628 /* TerminalGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4A45E115C85C714D8A91AC /* TerminalGeometryResolver.swift */; }; A8A294534897C461CA5968F3 /* UnitConversionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E7794DF60664B1FA8F6E7B /* UnitConversionEvaluator.swift */; }; A93A741C8C3973687D28F0B6 /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */; }; A982E243A4D8BC1BF7504B3E /* SuggestionInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */; }; @@ -508,6 +535,7 @@ B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; B02F46E94F74BAEBB90E165A /* PerformanceMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */; }; B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; + B2938005693EA9035E9C09E9 /* TerminalFocusAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220A7BA7C8FEB345C399C81F /* TerminalFocusAdapter.swift */; }; B2BDCFF0824EE41FC1C0451A /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */; }; B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */; }; @@ -531,6 +559,7 @@ B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; B816C6191738AB616F2E8D2D /* SuggestionCoordinatorTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */; }; B849D68E0474CECAE809881C /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC17643448271DE5DE61A89 /* DebouncePolicy.swift */; }; + B8C22B3FDC2E7385742BE8D8 /* TuiSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67585553297E95E55BF210ED /* TuiSessionDetector.swift */; }; B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; @@ -552,6 +581,7 @@ BEF746D51C69FE426376C89A /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */; }; BF60D99576F60321078464AD /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */; }; BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; }; + C02CBC2A2A9027CA6DB91A6A /* cotabby.bash in CopyFiles */ = {isa = PBXBuildFile; fileRef = B0520F0205BFEAC9C5FDDD3E /* cotabby.bash */; }; C0537A515AED443F6C61DB2A /* MenuBarSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; C0F757D74758B76DA2962BC5 /* LlamaDecodeGateDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3E5554AAC5D0007CCC61A7 /* LlamaDecodeGateDefaultsTests.swift */; }; @@ -577,13 +607,17 @@ C7849B86C5F2EE7D0A81AEF7 /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110CB0B53016644EF7840301 /* HuggingFaceAPIClient.swift */; }; C7E1F0365602914CAC03F342 /* SuggestionSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F050CE655081B840E361899E /* SuggestionSettingsStoreTests.swift */; }; C8CA6DACEAA83336551D4EFA /* SuggestionSettingsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93563FDA25DFC0038E5F887 /* SuggestionSettingsData.swift */; }; + C8D6CEEE645063972222168C /* TerminalFocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630C9C6AE4DAC991702B54DB /* TerminalFocusModels.swift */; }; C9426ADFCA2EC1D11D841A2A /* PerDomainDisableSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC582636750B78D497119845 /* PerDomainDisableSettingsTests.swift */; }; + C9B26002542B92B089B2B949 /* TuiContextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545CE5D92C1B449C12DE5F89 /* TuiContextReader.swift */; }; C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */; }; CA5B2D226FBAA5419E78F14F /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */; }; CADCC1B825DBE7BFC3135F43 /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */; }; CAFDC7A78647485F9C0AD167 /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; CB65A79F164269991FABC32E /* SuggestionStateHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */; }; + CBAE4652208B59B28D73488F /* cotabby.zsh in CopyFiles */ = {isa = PBXBuildFile; fileRef = 6C5C0715FC1B971341BE4C57 /* cotabby.zsh */; }; CC5EB06A46C84F05E300A903 /* ArithmeticEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7ED2D9D71CFAD9A30977E9 /* ArithmeticEvaluator.swift */; }; + CC93D7FC9B44C889FFAC0DCF /* ProcessTreeInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5353E2C9A1A83672FF8458 /* ProcessTreeInspector.swift */; }; CC98B842D10574C5206BEFA7 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */; }; CCB8D287A5FF3863B9DE9246 /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */; }; CCC83DC5AE51C17F153D5A6A /* PermissionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D82FFC568527700EC17C07D /* PermissionModels.swift */; }; @@ -596,6 +630,7 @@ D1D7D50E5C620042CEA3A77E /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E0AA9F90B83B823132880E6F /* LaunchAtLogin */; }; D21EBD25BCB37E69B633BC00 /* CotabbyDebugOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93AF1246C1C2E296A1162E63 /* CotabbyDebugOptionsTests.swift */; }; D22C324C4FDB813E54AAD113 /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */; }; + D2932867E073270A43403E46 /* TerminalFocusAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220A7BA7C8FEB345C399C81F /* TerminalFocusAdapter.swift */; }; D2B271E0FAE4F65FC2287930 /* SymSpellCorrector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */; }; D2BB9B47E0E1619021931B49 /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; }; D2CAA59F69BCBC07DDA2B0D8 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */; }; @@ -603,10 +638,12 @@ D368C9A8531677174956DE50 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */; }; D3B43622E5A41B11E7AF527E /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */; }; D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */; }; + D3D02459AB4A68B7A605EE99 /* cotabby.fish in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9B5337F491E20304196669AC /* cotabby.fish */; }; D46A0DB70B07F487431F48F6 /* EmojiPickerPanelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */; }; D46BCACE71169BF8403948CE /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */; }; D4DD6B6D22598BB3B98792DA /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; + D58FC61A39331D626B1BAC64 /* TerminalPromptAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460F21DD131DB683C688B13C /* TerminalPromptAnchor.swift */; }; D5CAF3B590E5EC2AFC72E57A /* VisualContextStartCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */; }; D603EC842D9D7A1A81899050 /* PermissionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D82FFC568527700EC17C07D /* PermissionModels.swift */; }; D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */; }; @@ -616,6 +653,7 @@ D80D98F6C7B86AAF8C831865 /* he-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7C9BB65FA5FC42B89766B037 /* he-100k.txt */; }; D81AC2294A64E7A95B29E081 /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; D90C889A623A928F4F5FDC7B /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; }; + D9C395FC033A3D21BEC3735E /* TerminalCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C99268965A1FAF025BD0E0F /* TerminalCompletionPromptRenderer.swift */; }; D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; }; DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */; }; DA9360725086404C953BCE5A /* OpenAICompatibleAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */; }; @@ -653,6 +691,7 @@ E74FC77608C8000C5E81B28E /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */; }; E853B9C7AF93FA595DC417B2 /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */; }; E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; }; + E92D8D8950FB12759A08A791 /* TuiContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F39E64E90E2FAD68BEBDFB /* TuiContextCoordinator.swift */; }; E95888E76AA68A18A88AD8E6 /* EmojiTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */; }; E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; E994FE418A961FB234D9057A /* DownloadFileRescuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */; }; @@ -665,6 +704,7 @@ ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */; }; ED51AE49BAD6DD5BD177F4F1 /* WelcomePersonalizeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */; }; ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */; }; + EDD6AF23DB474B7BABAE8A12 /* TuiContextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545CE5D92C1B449C12DE5F89 /* TuiContextReader.swift */; }; EE2C9177CE615298595215A8 /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; EE87886AC1BFC8BB3DE09762 /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */; }; EF0DE5E045F328F1E912A02A /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; @@ -691,6 +731,7 @@ F7AF84CA2F97023E1DA6296F /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; F8E86FA4D6CEEBFA7FB55F8D /* KeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */; }; F93F24A74E8942E26D82EFE3 /* GhostSuggestionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */; }; + F9929AAAF0F6B304E59109BF /* ShellPromptGeometryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861CD3419995CFE17DD85D93 /* ShellPromptGeometryCoordinator.swift */; }; FA25762161F068F59BEC86EB /* DecodeStopPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12ABBCE23A946C22894945B /* DecodeStopPolicy.swift */; }; FAC7FFC78BEBF62D5B7A2EFB /* DownloadOutcomeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */; }; FB0E2CE46002270A254E5FB3 /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */; }; @@ -722,6 +763,33 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 74DD63FB499E321DA9BD73A3 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "shell-integration"; + dstSubfolderSpec = 7; + files = ( + 1E0DE04F81FB92DD77043CC9 /* cotabby.bash in CopyFiles */, + D3D02459AB4A68B7A605EE99 /* cotabby.fish in CopyFiles */, + 77A1B96E29D83C79AD908D56 /* cotabby.zsh in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CFC0F3916CEAC7A5A7AB36DA /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "shell-integration"; + dstSubfolderSpec = 7; + files = ( + C02CBC2A2A9027CA6DB91A6A /* cotabby.bash in CopyFiles */, + 099485BFE060EB81A299E2E4 /* cotabby.fish in CopyFiles */, + CBAE4652208B59B28D73488F /* cotabby.zsh in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDebugLogger.swift; sourceTree = ""; }; 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPresentationObserver.swift; sourceTree = ""; }; @@ -767,6 +835,7 @@ 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGateTests.swift; sourceTree = ""; }; 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPaneScaffold.swift; sourceTree = ""; }; 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; + 1A22DF3811079416889294DB /* TerminalIntegrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalIntegrationService.swift; sourceTree = ""; }; 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolver.swift; sourceTree = ""; }; 1BA30E71C21C77BB6EA4C166 /* TokenCountEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCountEstimator.swift; sourceTree = ""; }; 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceSuppressionPolicy.swift; sourceTree = ""; }; @@ -782,6 +851,7 @@ 1F7ED2D9D71CFAD9A30977E9 /* ArithmeticEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluator.swift; sourceTree = ""; }; 210F9AD332273FE2EB3A9A01 /* WebContentFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetectorTests.swift; sourceTree = ""; }; 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; + 220A7BA7C8FEB345C399C81F /* TerminalFocusAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalFocusAdapter.swift; sourceTree = ""; }; 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; 224494DA9610B169F1C5BCFF /* SuggestionPauseModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPauseModelsTests.swift; sourceTree = ""; }; 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = ""; }; @@ -830,6 +900,7 @@ 39366D6D7D5946BF9330D038 /* MenuBarRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicyTests.swift; sourceTree = ""; }; 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouterTests.swift; sourceTree = ""; }; 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifier.swift; sourceTree = ""; }; + 3E107A7AF067CC58C3E3D6D8 /* TerminalIntegrationResourceInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalIntegrationResourceInstaller.swift; sourceTree = ""; }; 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = ""; }; 421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretRunPlacementTests.swift; sourceTree = ""; }; 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGateTests.swift; sourceTree = ""; }; @@ -837,9 +908,12 @@ 442507A5399ACD81D1DB4936 /* SuggestionFadeInPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFadeInPolicyTests.swift; sourceTree = ""; }; 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplate.swift; sourceTree = ""; }; 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPopoverDismisser.swift; sourceTree = ""; }; + 44CB467BB81B6A22D1F321EE /* TerminalWindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowScreenshotService.swift; sourceTree = ""; }; 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = ""; }; + 460F21DD131DB683C688B13C /* TerminalPromptAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPromptAnchor.swift; sourceTree = ""; }; 4638C74239D1DE2DC4D87975 /* MacroController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroController.swift; sourceTree = ""; }; 4696A84D17890B154533A08F /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; + 46F39E64E90E2FAD68BEBDFB /* TuiContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuiContextCoordinator.swift; sourceTree = ""; }; 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineModelsTests.swift; sourceTree = ""; }; 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetectorTests.swift; sourceTree = ""; }; 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageSupportTests.swift; sourceTree = ""; }; @@ -854,9 +928,11 @@ 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModePolicy.swift; sourceTree = ""; }; 53E41890930AA80910E461EF /* GhostFontMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostFontMetricsTests.swift; sourceTree = ""; }; 54150A507B03221F137D539B /* MirrorOverlayLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorOverlayLayout.swift; sourceTree = ""; }; + 545CE5D92C1B449C12DE5F89 /* TuiContextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuiContextReader.swift; sourceTree = ""; }; 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenFrameReader.swift; sourceTree = ""; }; 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedContextTests.swift; sourceTree = ""; }; 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; + 55D54FE82CEB8DE27E2E1839 /* TerminalCommandIntentPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandIntentPolicy.swift; sourceTree = ""; }; 565AB9A1265315C500FE5BDF /* PopupChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupChrome.swift; sourceTree = ""; }; 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngine.swift; sourceTree = ""; }; 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsWindowLocator.swift; sourceTree = ""; }; @@ -877,17 +953,20 @@ 620D393D3B7E687A08FA9446 /* es-100l.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "es-100l.txt"; sourceTree = ""; }; 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; + 630C9C6AE4DAC991702B54DB /* TerminalFocusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalFocusModels.swift; sourceTree = ""; }; 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; 66B53214C3842F78B202D498 /* AXHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelperTests.swift; sourceTree = ""; }; 66CF2A70D4699421AC9BD849 /* NOTICE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTICE.md; sourceTree = ""; }; 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecentsTests.swift; sourceTree = ""; }; + 67585553297E95E55BF210ED /* TuiSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuiSessionDetector.swift; sourceTree = ""; }; 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = ""; }; 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepGeometryWalkThrottle.swift; sourceTree = ""; }; 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; 6C34A98E5B05DB3F4FE81E70 /* ScreenTextExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTextExtractorTests.swift; sourceTree = ""; }; + 6C5C0715FC1B971341BE4C57 /* cotabby.zsh */ = {isa = PBXFileReference; path = cotabby.zsh; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; 6D4C1EF008B9DFA753D561D3 /* LlamaEvalScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaEvalScoringTests.swift; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; @@ -912,8 +991,10 @@ 78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; + 7A4A45E115C85C714D8A91AC /* TerminalGeometryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalGeometryResolver.swift; sourceTree = ""; }; 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = ""; }; 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = ""; }; + 7C99268965A1FAF025BD0E0F /* TerminalCompletionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCompletionPromptRenderer.swift; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; 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 = ""; }; @@ -927,12 +1008,14 @@ 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = ""; }; 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRenderer.swift; sourceTree = ""; }; + 861CD3419995CFE17DD85D93 /* ShellPromptGeometryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellPromptGeometryCoordinator.swift; sourceTree = ""; }; 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModel.swift; sourceTree = ""; }; 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRanker.swift; sourceTree = ""; }; 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; 87C309CD6A454C415D8BEEC7 /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumAccessibilityEnabler.swift; sourceTree = ""; }; 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneView.swift; sourceTree = ""; }; + 8A312D6EC80D6ABA9BDF098C /* TerminalAwareFocusModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAwareFocusModel.swift; sourceTree = ""; }; 8BF8DC1860CCF0DFA3A3DFD7 /* TextLayoutCaretEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimator.swift; sourceTree = ""; }; 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInteractionStateTests.swift; sourceTree = ""; }; 8CF6B7FFBF77B4290F5F2FB8 /* SurfaceContextCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextCache.swift; sourceTree = ""; }; @@ -964,6 +1047,7 @@ 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStyle.swift; sourceTree = ""; }; 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottleTests.swift; sourceTree = ""; }; + 9B5337F491E20304196669AC /* cotabby.fish */ = {isa = PBXFileReference; path = cotabby.fish; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; 9BC3BE51EFE0A1B2B13BD02B /* SuggestionAnchorCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAnchorCacheTests.swift; sourceTree = ""; }; @@ -1007,6 +1091,7 @@ AF1E065C7FFB697FCEB2FA5C /* CotabbyTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyTestFixtures.swift; sourceTree = ""; }; AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePaneView.swift; sourceTree = ""; }; AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudget.swift; sourceTree = ""; }; + B0520F0205BFEAC9C5FDDD3E /* cotabby.bash */ = {isa = PBXFileReference; path = cotabby.bash; sourceTree = ""; }; B1E6D9CCC0AA3674FEE57AE0 /* RuntimeBootstrapModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModelTests.swift; sourceTree = ""; }; B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRTextHygiene.swift; sourceTree = ""; }; B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerDomainDisableSettings.swift; sourceTree = ""; }; @@ -1030,6 +1115,7 @@ B81DD30EB657368AACE9625A /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = ""; }; B8412FE2BAC406421248A03B /* TypoGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGate.swift; sourceTree = ""; }; B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAppDetector.swift; sourceTree = ""; }; + BA5353E2C9A1A83672FF8458 /* ProcessTreeInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTreeInspector.swift; sourceTree = ""; }; BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverSelectionTests.swift; sourceTree = ""; }; BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitorTests.swift; sourceTree = ""; }; BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = ""; }; @@ -1096,10 +1182,12 @@ D84D4528EEC9EFEB8AE8E318 /* ActivationIndicatorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivationIndicatorController.swift; sourceTree = ""; }; D8C2663427BC5219EA415D00 /* ru.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ru.txt; sourceTree = ""; }; D93563FDA25DFC0038E5F887 /* SuggestionSettingsData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsData.swift; sourceTree = ""; }; + D979E5C579D257630B50F45C /* TuiFocusAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuiFocusAdapter.swift; sourceTree = ""; }; D9B27E3D3FD8008CE2C1B616 /* CotabbyBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyBrand.swift; sourceTree = ""; }; D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsPaneView.swift; sourceTree = ""; }; DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContainerView.swift; sourceTree = ""; }; DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerSourceMonitor.swift; sourceTree = ""; }; + DC4F0307BEE22C10F984AB70 /* TerminalIntegrationPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalIntegrationPaths.swift; sourceTree = ""; }; DDC034BBCBAC5E7989D4C85B /* LlamaSuggestionEngineStreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineStreamingTests.swift; sourceTree = ""; }; DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactory.swift; sourceTree = ""; }; DDF6A4E9CE93FD53C60E67E3 /* EmojiQueryRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRun.swift; sourceTree = ""; }; @@ -1140,6 +1228,7 @@ F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimatorTests.swift; sourceTree = ""; }; F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryCatalog.swift; sourceTree = ""; }; F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateMacroEvaluator.swift; sourceTree = ""; }; + F7DCEB58889CA5C05D96E062 /* TerminalIntegrationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalIntegrationModelTests.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 = ""; }; FB317C82CE2CBC69056BA4B8 /* TagChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagChip.swift; sourceTree = ""; }; @@ -1230,6 +1319,17 @@ path = Power; sourceTree = ""; }; + 1E5A42D79AD6858AED8B29BB /* shell-integration */ = { + isa = PBXGroup; + children = ( + B0520F0205BFEAC9C5FDDD3E /* cotabby.bash */, + 9B5337F491E20304196669AC /* cotabby.fish */, + 6C5C0715FC1B971341BE4C57 /* cotabby.zsh */, + ); + name = "shell-integration"; + path = "scripts/shell-integration"; + sourceTree = ""; + }; 1F3BF5BD8958D0D0C2E34AF1 /* Permission */ = { isa = PBXGroup; children = ( @@ -1274,6 +1374,20 @@ path = Panes; sourceTree = ""; }; + 36136E4D50E4F52DDE6D3B69 /* Terminal */ = { + isa = PBXGroup; + children = ( + 861CD3419995CFE17DD85D93 /* ShellPromptGeometryCoordinator.swift */, + 7A4A45E115C85C714D8A91AC /* TerminalGeometryResolver.swift */, + 3E107A7AF067CC58C3E3D6D8 /* TerminalIntegrationResourceInstaller.swift */, + 1A22DF3811079416889294DB /* TerminalIntegrationService.swift */, + 44CB467BB81B6A22D1F321EE /* TerminalWindowScreenshotService.swift */, + 46F39E64E90E2FAD68BEBDFB /* TuiContextCoordinator.swift */, + 545CE5D92C1B449C12DE5F89 /* TuiContextReader.swift */, + ); + path = Terminal; + sourceTree = ""; + }; 3725B1A195A06A4F014B75B1 /* Products */ = { isa = PBXGroup; children = ( @@ -1431,6 +1545,9 @@ 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */, DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */, 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */, + 8A312D6EC80D6ABA9BDF098C /* TerminalAwareFocusModel.swift */, + 630C9C6AE4DAC991702B54DB /* TerminalFocusModels.swift */, + DC4F0307BEE22C10F984AB70 /* TerminalIntegrationPaths.swift */, BE97A8169438D593C6C23412 /* VisualContextModels.swift */, ); path = Models; @@ -1624,6 +1741,7 @@ 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */, 9255CBCDE66253F521EE0F08 /* SystemResourceSamplerTests.swift */, 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */, + F7DCEB58889CA5C05D96E062 /* TerminalIntegrationModelTests.swift */, FC24FD54860CE6737E65EF65 /* TextDirectionDetectorTests.swift */, F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */, B78AA11B52A6588119ABF76F /* TokenCountEstimatorTests.swift */, @@ -1714,6 +1832,7 @@ 0AD8D6729549734CA7E06926 /* Config */, 10701CD228D9F70454402BED /* Cotabby */, A8A141E1F2137943476D0C82 /* CotabbyTests */, + 1E5A42D79AD6858AED8B29BB /* shell-integration */, 3725B1A195A06A4F014B75B1 /* Products */, ); sourceTree = ""; @@ -1740,6 +1859,7 @@ FFF55D3CE9FF3B16845823F7 /* Runtime */, 6495533696D4461386265D05 /* Spelling */, 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */, + 36136E4D50E4F52DDE6D3B69 /* Terminal */, E2E02780CEFBC8027DEEB398 /* UI */, 41435C2343611783BD33423E /* Utilities */, 2574DDA4AFE2DE99E608EF95 /* Visual */, @@ -1810,6 +1930,7 @@ FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */, B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */, E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */, + BA5353E2C9A1A83672FF8458 /* ProcessTreeInspector.swift */, FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */, AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */, 6DC693E00430F46E41CB56E6 /* RequestID.swift */, @@ -1834,10 +1955,16 @@ 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */, 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */, 7F4C4A7EAF886E0CC945BFEF /* TerminalAppDetector.swift */, + 55D54FE82CEB8DE27E2E1839 /* TerminalCommandIntentPolicy.swift */, + 7C99268965A1FAF025BD0E0F /* TerminalCompletionPromptRenderer.swift */, + 220A7BA7C8FEB345C399C81F /* TerminalFocusAdapter.swift */, + 460F21DD131DB683C688B13C /* TerminalPromptAnchor.swift */, 328847A0F494360033366791 /* TextDirectionDetector.swift */, 8BF8DC1860CCF0DFA3A3DFD7 /* TextLayoutCaretEstimator.swift */, 1BA30E71C21C77BB6EA4C166 /* TokenCountEstimator.swift */, D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */, + D979E5C579D257630B50F45C /* TuiFocusAdapter.swift */, + 67585553297E95E55BF210ED /* TuiSessionDetector.swift */, 08CE63B8725EBD71A4C024E1 /* TypoCaseTransfer.swift */, B8412FE2BAC406421248A03B /* TypoGate.swift */, 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */, @@ -1872,6 +1999,7 @@ buildPhases = ( 5ADB1600B55A6BA5DB3DEB1F /* Sources */, 89FA0B011090823F93FF340E /* Resources */, + CFC0F3916CEAC7A5A7AB36DA /* CopyFiles */, D66643968D3BCEBA2ADCF895 /* Frameworks */, ); buildRules = ( @@ -1896,6 +2024,7 @@ buildPhases = ( B46510C95913ADDBA972CADE /* Sources */, 5BD676C1D87B642F7A4BF214 /* Resources */, + 74DD63FB499E321DA9BD73A3 /* CopyFiles */, 658F2109EF7F1517DEA48DEB /* Frameworks */, ); buildRules = ( @@ -2213,6 +2342,7 @@ D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */, 2D32B63CB58FBC5BC2EA7125 /* PopupChrome.swift in Sources */, C607A624A0FB697486C56B8E /* PowerSourceMonitor.swift in Sources */, + 99C354D2C57B57553FD40E7D /* ProcessTreeInspector.swift in Sources */, FEC24B9C23274B9FA1F0072E /* PromptContextSanitizer.swift in Sources */, 982BA68A53CEE0F45D41F3D3 /* PromptSectionBudget.swift in Sources */, 9A419D0704C95920CB71D3B1 /* RandomMacroEvaluator.swift in Sources */, @@ -2237,6 +2367,7 @@ BC27A2F336857345642A30E5 /* SettingsSearchRanker.swift in Sources */, B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */, 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */, + F9929AAAF0F6B304E59109BF /* ShellPromptGeometryCoordinator.swift in Sources */, 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */, B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */, AD361AA6F90A5E5F6F5005BF /* SpellingDictionaryCatalog.swift in Sources */, @@ -2280,11 +2411,26 @@ BF60D99576F60321078464AD /* SystemSettingsWindowLocator.swift in Sources */, BD77B0CFB09BF0B4EDB1B0C6 /* TagChip.swift in Sources */, 6D57E3CDF56127422311C065 /* TerminalAppDetector.swift in Sources */, + 513A2637FC41A59B2DC3E66B /* TerminalAwareFocusModel.swift in Sources */, + 010BE97D4679BDD23C2807A5 /* TerminalCommandIntentPolicy.swift in Sources */, + D9C395FC033A3D21BEC3735E /* TerminalCompletionPromptRenderer.swift in Sources */, + B2938005693EA9035E9C09E9 /* TerminalFocusAdapter.swift in Sources */, + C8D6CEEE645063972222168C /* TerminalFocusModels.swift in Sources */, + 850B02C1791A4E9BF87CCC3E /* TerminalGeometryResolver.swift in Sources */, + 96F228C25A85CF2822EC01F2 /* TerminalIntegrationPaths.swift in Sources */, + 6B7AF50F895BC735896B9BAB /* TerminalIntegrationResourceInstaller.swift in Sources */, + 9730D96D5776DFF8C5DC5F32 /* TerminalIntegrationService.swift in Sources */, + 3E19521F68F376B336E871BB /* TerminalPromptAnchor.swift in Sources */, + 3199BF69F9C6A51DB89803AA /* TerminalWindowScreenshotService.swift in Sources */, 6A4E62EC9B7B970695F87136 /* TextDirectionDetector.swift in Sources */, 0777169DC861BD87C4C1D729 /* TextLayoutCaretEstimator.swift in Sources */, 91ADE463EE72D77E0D3EBBCA /* TickMarkSlider.swift in Sources */, 1C164EC9452EFB38173D227E /* TokenCountEstimator.swift in Sources */, 64A36D1EE1BF29AF5B58906A /* TrailingDuplicationFilter.swift in Sources */, + E92D8D8950FB12759A08A791 /* TuiContextCoordinator.swift in Sources */, + EDD6AF23DB474B7BABAE8A12 /* TuiContextReader.swift in Sources */, + 7EBDA2B2663D380DD509DE4D /* TuiFocusAdapter.swift in Sources */, + B8C22B3FDC2E7385742BE8D8 /* TuiSessionDetector.swift in Sources */, C178E35A9A713BD4D9943E62 /* TypoCaseTransfer.swift in Sources */, 06B7E7339877B334B28BE2D3 /* TypoGate.swift in Sources */, A8A294534897C461CA5968F3 /* UnitConversionEvaluator.swift in Sources */, @@ -2469,6 +2615,7 @@ 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */, 2DA8DFFE8DF9B6AD71E28417 /* PopupChrome.swift in Sources */, 50E1247BE6A952AB1B12C444 /* PowerSourceMonitor.swift in Sources */, + CC93D7FC9B44C889FFAC0DCF /* ProcessTreeInspector.swift in Sources */, 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */, 3C561CD717064F9250200667 /* PromptSectionBudget.swift in Sources */, 25750F599635ED38E61517A3 /* RandomMacroEvaluator.swift in Sources */, @@ -2493,6 +2640,7 @@ D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */, E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, + 9C98C1CE04422343F371C948 /* ShellPromptGeometryCoordinator.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, 4B2C52C714D04D17ACB70B99 /* SpeculativeAcceptanceContext.swift in Sources */, 2E972FB7E0CF14EE03AA55A3 /* SpellingDictionaryCatalog.swift in Sources */, @@ -2536,11 +2684,26 @@ 258EAFB0292290C88520E915 /* SystemSettingsWindowLocator.swift in Sources */, 90CD3F7238E223DEBA2B4D92 /* TagChip.swift in Sources */, AB9C9C001F97F9D14F8B192A /* TerminalAppDetector.swift in Sources */, + 403AD55C84EAA9C1B8A36A6C /* TerminalAwareFocusModel.swift in Sources */, + 22A86A30CCF5A0A335FA7CAB /* TerminalCommandIntentPolicy.swift in Sources */, + 3E19BC087F0D5C54B150C75C /* TerminalCompletionPromptRenderer.swift in Sources */, + D2932867E073270A43403E46 /* TerminalFocusAdapter.swift in Sources */, + 73704AE903CDD0B7800C89B6 /* TerminalFocusModels.swift in Sources */, + A89810FF966941669F0FE628 /* TerminalGeometryResolver.swift in Sources */, + 2D303F5BDB40B173F9DC44E6 /* TerminalIntegrationPaths.swift in Sources */, + 6BCDA2747374467630233EF5 /* TerminalIntegrationResourceInstaller.swift in Sources */, + 042C13DE7F25CF9927C14DD4 /* TerminalIntegrationService.swift in Sources */, + D58FC61A39331D626B1BAC64 /* TerminalPromptAnchor.swift in Sources */, + 2C6D811DA33B73E75E70CDEB /* TerminalWindowScreenshotService.swift in Sources */, 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */, AA0B5EC6BC922E143971F8AD /* TextLayoutCaretEstimator.swift in Sources */, 6014B31E2570EFFE45557E33 /* TickMarkSlider.swift in Sources */, DC84D6A6A2F9A1060CD20ABB /* TokenCountEstimator.swift in Sources */, D3B43622E5A41B11E7AF527E /* TrailingDuplicationFilter.swift in Sources */, + 628D77202079BF5B7DFD5EE7 /* TuiContextCoordinator.swift in Sources */, + C9B26002542B92B089B2B949 /* TuiContextReader.swift in Sources */, + 9B9A1326D35113E81C853A42 /* TuiFocusAdapter.swift in Sources */, + 3E1D7831517F123964CA9D5B /* TuiSessionDetector.swift in Sources */, B709B362B786AA6ED548C673 /* TypoCaseTransfer.swift in Sources */, E311B80968761E90FBA19A8A /* TypoGate.swift in Sources */, B6815BBBC91809A6EAA914CB /* UnitConversionEvaluator.swift in Sources */, @@ -2710,6 +2873,7 @@ 856082F4732206A3761816DC /* SystemMetricsStoreTests.swift in Sources */, C6A112B51525F988EA46F725 /* SystemResourceSamplerTests.swift in Sources */, DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */, + 73408AA20D4EB52B6CC9DB38 /* TerminalIntegrationModelTests.swift in Sources */, 5A441797D71A880A7482077D /* TextDirectionDetectorTests.swift in Sources */, 2B1F6B9BD4DEBD7DA4DD9855 /* TextLayoutCaretEstimatorTests.swift in Sources */, A26E14A6E73036222419C424 /* TokenCountEstimatorTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index d192448b..075dafa1 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -66,8 +66,12 @@ extension SuggestionCoordinator { // the corrected one in one gesture. Partial acceptance would be incoherent because the // corrected word and the typo can disagree on prefix length, so route to a dedicated path // before the continuation preparation below. - if activeSession.kind.isCorrection { - return acceptCorrection(session: activeSession, keyName: keyName, rawContext: rawContext) + if let replacementResult = acceptReplacementSessionIfNeeded( + activeSession, + keyName: keyName, + rawContext: rawContext + ) { + return replacementResult } // `acceptEntireSuggestion` forces the full-acceptance path so the dedicated full-accept key @@ -123,7 +127,9 @@ extension SuggestionCoordinator { // An empty chunk means the accepted span was entirely a boundary space the field already // supplies: advance the session without synthesizing a keystroke. - if !insertionText.isEmpty, !suggestionInserter.insert(insertionText) { + let insertionMode = insertionMode(for: rawContext) + if !insertionText.isEmpty, + !suggestionInserter.insert(insertionText, mode: insertionMode) { let message = suggestionInserter.lastErrorMessage ?? "Suggestion insertion failed." cancelPredictionWork() clearSuggestion(clearDiagnostics: true) @@ -183,22 +189,13 @@ extension SuggestionCoordinator { // *after* the `hideOverlay` above, which routes through `onStateChange(.hidden)` and // turns interception off; arming re-asserts it. See `armPostExhaustionAcceptance`. armPostExhaustionAcceptance() - // Start the continuation against the text the host is about to publish instead of - // idling through the publish poll first; the poll below validates the bet (see - // dispatchSpeculativePostAcceptanceGeneration). - dispatchSpeculativePostAcceptanceGeneration( + return refreshAfterExhaustedAcceptance( + mode: insertionMode, + context: liveContext, + insertedText: insertionText, rawContext: rawContext, insertionChunk: insertionChunk ) - // Wait for the host to actually publish the inserted text before regenerating. A bare - // `schedulePrediction()` here reads pre-insertion AX in Chromium editors (the publish lags - // the synthetic keystroke), so the model re-proposes the word just accepted and the next - // accept re-inserts it. That is the final-word accept/regenerate/accept loop reported as - // the suggestion "flickering" without committing. Polling until the insert surfaces (the - // same path typing uses) makes the regeneration read the settled text and return a genuine - // next suggestion, or nothing. - schedulePredictionAfterHostPublishDelay() - return true case let .advanced(advancedSession, _): latestGenerationNumber = liveContext.generation @@ -211,7 +208,11 @@ extension SuggestionCoordinator { insertionChunk: insertionChunk, liveContext: liveContext ) - schedulePostInsertionRefresh() + refreshAfterAdvancedAcceptance( + mode: insertionMode, + context: liveContext, + insertedText: insertionText + ) let workID = currentWorkID deferAcceptanceBookkeeping { [weak self] in self?.applySessionDiagnostics( @@ -230,6 +231,69 @@ extension SuggestionCoordinator { } } + /// Publishes Cotabby's optimistic shell echo and tells the caller whether AX refresh must be + /// skipped. TUI input also returns true, but its OCR heartbeat supplies the eventual value. + private func publishTerminalInsertionIfNeeded( + mode: SuggestionInsertionMode, + context: FocusedInputContext, + insertedText: String + ) -> Bool { + guard mode == .terminalPaste else { return false } + if !insertedText.isEmpty { + onTerminalInsertion?(context, insertedText) + } + return true + } + + private func insertionMode(for context: FocusedInputSnapshot) -> SuggestionInsertionMode { + context.terminalInputRole.map { _ in .terminalPaste } ?? .automatic + } + + private func refreshAfterAdvancedAcceptance( + mode: SuggestionInsertionMode, + context: FocusedInputContext, + insertedText: String + ) { + if !publishTerminalInsertionIfNeeded( + mode: mode, + context: context, + insertedText: insertedText + ) { + schedulePostInsertionRefresh() + } + } + + private func refreshAfterExhaustedAcceptance( + mode: SuggestionInsertionMode, + context: FocusedInputContext, + insertedText: String, + rawContext: FocusedInputSnapshot, + insertionChunk: String + ) -> Bool { + if publishTerminalInsertionIfNeeded( + mode: mode, + context: context, + insertedText: insertedText + ) { + // The authoritative terminal source is the host-publication signal. Never poll AX. + // When acceptance consumed only a boundary already present in the buffer, there is no + // paste for that source to publish, so explicitly refresh against the unchanged value. + if insertedText.isEmpty { + schedulePrediction() + } + return true + } + + // Speculate against the imminent host value, then use the normal AX publication poll as + // the validator. This retains the existing Chromium accept/regenerate race protection. + dispatchSpeculativePostAcceptanceGeneration( + rawContext: rawContext, + insertionChunk: insertionChunk + ) + schedulePredictionAfterHostPublishDelay() + return true + } + /// Applies the opt-in "add a space after accepting" setting to the chunk being accepted, by /// extending a word-ending chunk to also take the suggestion's own following space. The whole /// flow (insertion text, session advance, overlay) then runs on the extended chunk, so the space @@ -423,6 +487,84 @@ extension SuggestionCoordinator { return true } + /// Routes both accept-as-a-unit session kinds away from continuation chunking. `nil` means the + /// session is a normal continuation; a non-nil Bool is the concrete acceptance outcome. + private func acceptReplacementSessionIfNeeded( + _ session: ActiveSuggestionSession, + keyName: String, + rawContext: FocusedInputSnapshot + ) -> Bool? { + switch session.kind { + case .continuation: + return nil + case .correction: + return acceptCorrection(session: session, keyName: keyName, rawContext: rawContext) + case .terminalCommandReplacement: + return acceptTerminalCommandReplacement( + session: session, + keyName: keyName, + rawContext: rawContext + ) + } + } + + /// Replaces a reviewed plain-English shell instruction with the complete generated command. + /// The command is pasted but Return is never synthesized, so destructive output remains visible + /// and inert until the user explicitly executes it. Exact live-buffer validation prevents a + /// delayed Tab from deleting text the user changed after generation. + private func acceptTerminalCommandReplacement( + session: ActiveSuggestionSession, + keyName: String, + rawContext: FocusedInputSnapshot + ) -> Bool { + guard case let .terminalCommandReplacement(originalText) = session.kind, + rawContext.terminalInputRole == .shell, + rawContext.precedingText == originalText, + rawContext.trailingText.isEmpty, + rawContext.elementIdentifier == session.baseContext.elementIdentifier else { + return passTabThrough( + reason: "Key passed through because the shell instruction changed before replacement." + ) + } + + guard suggestionInserter.replaceTerminalLine( + deletingCharacterCount: originalText.count, + with: session.fullText + ) else { + let message = suggestionInserter.lastErrorMessage ?? "Terminal command replacement failed." + cancelPredictionWork() + clearSuggestion(clearDiagnostics: true) + hideOverlay(reason: "Overlay hidden because terminal command replacement failed.") + state = .idle + logStage( + "terminal-command-replace-failed", + workID: currentWorkID, + generation: session.baseContext.generation, + message: message, + normalizedOutput: session.fullText + ) + return false + } + + onTerminalReplacement?(session.baseContext, session.fullText) + lastAcceptanceAt = Date() + focusModel.invalidateTransientCaretCaches() + cancelPredictionWork() + latestGenerationNumber = session.baseContext.generation + clearSuggestion(clearDiagnostics: false) + hideOverlay(reason: "Overlay hidden because \(keyName) replaced the shell instruction.") + state = .idle + latestAcceptanceAction = "Replaced shell instruction with a command using \(keyName)." + logStage( + "\(keyName)-accepted-terminal-command-replacement", + workID: currentWorkID, + generation: session.baseContext.generation, + message: "Replaced the reviewed plain-English shell instruction without executing it.", + normalizedOutput: session.fullText + ) + return true + } + /// Returns control of the accept key to the host app and clears stale suggestion UI. /// /// `InputMonitor` calls this from the consuming tap before returning a callback result. A `false` @@ -584,7 +726,7 @@ extension SuggestionCoordinator { CotabbyLogger.suggestion.debug("Invalidating active suggestion: \(reason)") // The dying session is exactly what a backspace-rollback wants restored a moment later; // remember it (string-only) before the state is torn down. - if let session = interactionState.activeSession, !session.kind.isCorrection { + if let session = interactionState.activeSession, !session.kind.isReplacement { suggestionAnchorCache.record( identityKey: session.baseContext.focusedInputIdentityKey, precedingText: session.baseContext.precedingText, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index ce66515f..713ccb27 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -26,6 +26,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusModel.snapshot ) { @@ -63,6 +64,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: snapshot, @@ -93,6 +95,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: snapshot, @@ -126,6 +129,20 @@ extension SuggestionCoordinator { if overlayState.isVisible { hideOverlay(reason: "Overlay hidden because no ready suggestion remains.") } + + // AX fields are scheduled from the key event after their host publishes the edit. Terminal + // sources are the opposite: the socket/OCR snapshot itself is the authoritative publish, + // so schedule once per source signature and never poll the opaque AX terminal surface. + if focusedContext.terminalInputRole != nil { + let signature = focusedContext.contentSignature + if signature != lastScheduledTerminalSignature, + SuggestionRequestFactory.shouldGenerateSuggestion(for: focusedContext.precedingText) { + lastScheduledTerminalSignature = signature + schedulePrediction() + } + } else { + lastScheduledTerminalSignature = nil + } } /// Fire-and-forget warmup that builds a minimal request shape for the current focus and asks @@ -155,11 +172,16 @@ extension SuggestionCoordinator { } func handleInputEvent(_ event: CapturedInputEvent) -> Bool { + // TUI OCR must see keystrokes even before it owns effective focus; dedicated terminals are + // disabled in the normal evaluator until that verified OCR snapshot arrives. + tuiInputObserver?(event) + // Give the emoji picker first look at every keystroke so it can drive its trigger state // machine. When a capture is involved, the picker owns the interaction: the suggestion // pipeline stands down and any lingering ghost text is cleared so it does not show behind the // panel. Consumption still happens through the active tap's `emojiCaptureKeyDecider`. - if emojiInputObserver?(event) == true { + if focusModel.snapshot.context?.terminalInputRole == nil, + emojiInputObserver?(event) == true { if overlayState.isVisible || interactionState.activeSession != nil { cancelPredictionWork() clearSuggestion(clearDiagnostics: true) @@ -199,17 +221,22 @@ extension SuggestionCoordinator { } if event.shouldSchedulePrediction { - // Same Chromium AX-publish race as the with-session paths below: the CGEvent tap runs - // *before* the host app processes the keystroke, so a synchronous `refreshNow()` here - // reads pre-keystroke text and feeds it into generation. The result is a suggestion - // that looks like the typed character was ignored — see - // `schedulePredictionAfterHostPublishDelay` for the full rationale. - schedulePredictionAfterHostPublishDelay() + schedulePredictionAfterInputEvent() } return false } + private func schedulePredictionAfterInputEvent() { + guard focusModel.snapshot.context?.terminalInputRole == nil else { + // The terminal source will schedule when its post-keystroke value arrives. + return + } + // The event tap runs before the host processes the key. Poll normal AX fields until they + // publish; terminal sources use their own authoritative publication path above. + schedulePredictionAfterHostPublishDelay() + } + /// Maximum wall time we'll wait for the host app to publish post-keystroke AX before giving /// up and generating against whatever's there. Chosen empirically: long enough to cover /// Chrome's slower contenteditable publish on a busy page, short enough that the user can @@ -241,6 +268,11 @@ extension SuggestionCoordinator { /// still collapse cleanly. The `hostPublishPollGeneration` token adds the missing outer /// coalescing layer: only the newest keystroke's polling chain may keep reading AX. func schedulePredictionAfterHostPublishDelay() { + guard focusModel.snapshot.context?.terminalInputRole == nil else { + // Shell hooks and TUI OCR are their own post-edit publication channels. Refreshing AX + // here would only read terminal output and could never validate the authoritative text. + return + } hostPublishPollGeneration &+= 1 let pollGeneration = hostPublishPollGeneration let baseline = focusModel.snapshot.context diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift index f167521d..c0e7930a 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift @@ -25,6 +25,7 @@ extension SuggestionCoordinator { overlayController.onStateChange = nil visualContextCoordinator.onStateChange = nil visualContextCoordinator.onInjectedContextReady = nil + lastScheduledTerminalSignature = nil } /// Clears any active suggestion work before the runtime swaps to a different model. @@ -38,6 +39,7 @@ extension SuggestionCoordinator { clearSuggestion(clearDiagnostics: true) hideOverlay(reason: "Overlay hidden because the runtime model is switching.") state = .idle + lastScheduledTerminalSignature = nil latestStageMessage = "Idle: runtime model switching reset active suggestion state." } @@ -76,6 +78,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot, @@ -90,6 +93,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusModel.snapshot ) { diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 83ac9133..77875f39 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -73,7 +73,9 @@ extension SuggestionCoordinator { // key event, because the user may have switched apps or fields during the debounce window. // The host-publish poll usually captured one milliseconds ago, though, so a fresh-enough // capture is reused instead of paying another synchronous AX walk back to back. - focusModel.refreshIfStale(maxAgeMilliseconds: Self.freshSnapshotReuseWindowMilliseconds) + if focusModel.snapshot.context?.terminalInputRole == nil { + focusModel.refreshIfStale(maxAgeMilliseconds: Self.freshSnapshotReuseWindowMilliseconds) + } let snapshot = focusModel.snapshot if let disabledReason = currentDisabledReason(focusSnapshot: snapshot) { @@ -98,7 +100,8 @@ extension SuggestionCoordinator { // pile onto a broken word), presents a green correction, or automatically fixes a completed // word after Space. Native correction is instant and needs no model generation, so it is // handled synchronously and returns before any request runs. - if handleTypoGate(rawContext: rawContext, workID: workID) { + if rawContext.terminalInputRole == nil, + handleTypoGate(rawContext: rawContext, workID: workID) { return } @@ -278,7 +281,11 @@ extension SuggestionCoordinator { // engine skips its per-token main-actor hops entirely and the suggestion appears once, fully // formed, through `apply` below; when on, each partial renders as an acceptable session the // user can Tab into early. + // Replacement output is accept-as-one-unit. Streaming it would briefly create ordinary + // continuation sessions whose partial command could be accepted before the full command + // (including closing quotes or paths) arrives. let shouldStreamPartials = settingsSnapshot.streamSuggestionsWhileGenerating + && !request.mode.isTerminalCommandReplacement workController.replaceGenerationWork(for: workID) { [weak self] in guard let self else { return @@ -729,6 +736,17 @@ extension SuggestionCoordinator { return } + guard let sessionKind = resolvedSessionKind( + for: result.mode, + liveContext: liveContext + ) else { + clearSuggestion(clearDiagnostics: true) + hideOverlay(reason: "Overlay hidden because the shell instruction changed before translation finished.") + state = .idle + qualityMetricsStore.recordSuppressed(reason: "discardedStaleTerminalReplacement") + return + } + // A regeneration that only re-proposes the just-accepted tail while the field still shows the // pre-acceptance text means our insert has not published yet. Drop it so the next accept can't // re-insert the same word and spin the final-word loop. @@ -760,7 +778,12 @@ extension SuggestionCoordinator { let seamVerdict = CompletionSeamGuard.verdict( precedingText: liveContext.precedingText, completion: result.text, - isKnownWord: { !spellChecker.isTypo($0) } + isKnownWord: { + // Shell commands, flags, package names, and paths are routinely absent from the + // spelling dictionary (`kubectl`, `rg`, `--no-verify`). Keep the punctuation-junk + // half of the seam guard, but do not apply prose spelling policy to terminal roles. + liveContext.terminalInputRole != nil || !spellChecker.isTypo($0) + } ) if seamVerdict != .allow { clearSuggestion() @@ -783,15 +806,16 @@ extension SuggestionCoordinator { // One shown event per suggestion: this is the only place a fresh generation becomes // visible (re-presentations after partial accepts reuse the same session). qualityMetricsStore.recordShown() - suggestionAnchorCache.record( - identityKey: liveContext.focusedInputIdentityKey, - precedingText: liveContext.precedingText, - fullText: result.text + recordSuggestionAnchorIfNeeded( + kind: sessionKind, + context: liveContext, + text: result.text ) let session = interactionState.startSession( fullText: result.text, liveContext: liveContext, - latency: result.latency + latency: result.latency, + kind: sessionKind ) applySessionDiagnostics(session, acceptanceAction: "Generated new suggestion.") state = .ready(text: session.remainingText, latency: session.latency) @@ -800,7 +824,8 @@ extension SuggestionCoordinator { text: session.remainingText, at: liveContext.caretRect, context: liveContext, - isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) + isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText), + isCorrection: sessionKind.isReplacement ) logStage( "ready", @@ -817,6 +842,36 @@ extension SuggestionCoordinator { flushQueuedPostExhaustionAcceptIfNeeded() } + /// Converts the request/result mode into durable session behavior while validating the exact + /// shell buffer a whole-line translation is allowed to replace. + private func resolvedSessionKind( + for mode: SuggestionRequestMode, + liveContext: FocusedInputContext + ) -> SuggestionKind? { + switch mode { + case .continuation: + return .continuation + case let .terminalCommandReplacement(originalText): + guard liveContext.terminalInputRole == .shell, + liveContext.precedingText == originalText, + liveContext.trailingText.isEmpty else { return nil } + return .terminalCommandReplacement(originalText: originalText) + } + } + + private func recordSuggestionAnchorIfNeeded( + kind: SuggestionKind, + context: FocusedInputContext, + text: String + ) { + guard !kind.isReplacement else { return } + suggestionAnchorCache.record( + identityKey: context.focusedInputIdentityKey, + precedingText: context.precedingText, + fullText: text + ) + } + /// Converts a runtime or engine failure into visible coordinator state and clears stale UI. func applyFailure(_ message: String, workID: UInt64) async { guard workController.isCurrent(workID) else { @@ -863,6 +918,10 @@ extension SuggestionCoordinator { reconcileCorrectionSession(activeSession, with: snapshot) return } + if case .terminalCommandReplacement = activeSession.kind { + reconcileTerminalCommandReplacementSession(activeSession, with: snapshot) + return + } guard case .supported = snapshot.capability, let rawContext = snapshot.context else { // Browser-based editors can transiently report "no usable text field" for a single AX @@ -991,6 +1050,27 @@ extension SuggestionCoordinator { ) } + /// A command translation is valid only for the exact shell line that produced it. Unlike a + /// continuation there is no meaningful partial advancement: any edit, cursor move, source + /// switch, or trailing text invalidates the proposed whole-line replacement. + private func reconcileTerminalCommandReplacementSession( + _ session: ActiveSuggestionSession, + with snapshot: FocusSnapshot + ) { + guard case let .terminalCommandReplacement(originalText) = session.kind, + case .supported = snapshot.capability, + let live = snapshot.context, + live.terminalInputRole == .shell, + live.elementIdentifier == session.baseContext.elementIdentifier, + live.precedingText == originalText, + live.trailingText.isEmpty else { + invalidateActiveSuggestion( + reason: "Overlay hidden because the shell instruction changed after translation." + ) + return + } + } + /// The single marshalling point for `SuggestionAvailabilityEvaluator.disabledReason`: every gate /// in the input and prediction paths shares the same settings, permission, and per-domain inputs, /// and varies only by which focus snapshot it is checking. Returns the user-facing disable reason, @@ -1002,6 +1082,7 @@ extension SuggestionCoordinator { disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActiveProvider(), inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusSnapshot ) @@ -1018,6 +1099,7 @@ extension SuggestionCoordinator { } CotabbyLogger.suggestion.debug("Predictions disabled: \(reason)") + lastScheduledTerminalSignature = nil cancelPredictionWork() resetCachedGenerationContext() visualContextCoordinator.cancel(resetState: true) @@ -1039,6 +1121,7 @@ extension SuggestionCoordinator { return } + lastScheduledTerminalSignature = nil cancelPredictionWork() resetCachedGenerationContext() interactionState.resetAll() diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index a2fe82dc..12b0def9 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -71,6 +71,22 @@ final class SuggestionCoordinator: ObservableObject { /// consumption happens through `InputMonitor.emojiCaptureKeyDecider`. var emojiInputObserver: ((CapturedInputEvent) -> Bool)? + /// Side-effect observer for Claude Code's OCR source. It receives the listen-only keystroke + /// stream and decides whether a debounced capture is warranted; normal apps pay only a no-op. + var tuiInputObserver: ((CapturedInputEvent) -> Void)? + + /// Reports a successful terminal paste after the interaction state has committed the accepted + /// chunk. The shell service uses it for an optimistic local echo because bracketed paste is not + /// visible to shell editor hooks until the next physical keystroke. + var onTerminalInsertion: ((FocusedInputContext, String) -> Void)? + + /// Reports a successful whole-line command translation so the shell source can replace its + /// optimistic buffer instead of appending the command to the original English instruction. + var onTerminalReplacement: ((FocusedInputContext, String) -> Void)? + + /// Supplies the live authoritative-source gate used by every availability decision. + var terminalIntegrationActiveProvider: () -> Bool = { false } + static let totalTabAcceptedWordCountDefaultsKey = "cotabbyTotalAcceptedWordCount" // Combine subscriptions are the coordinator's remaining direct mutable bookkeeping. @@ -122,6 +138,9 @@ final class SuggestionCoordinator: ObservableObject { /// synchronous `refreshNow()` calls on the main actor. Bumping the token makes older chains /// no-op before they can perform another expensive AX read. var hostPublishPollGeneration: UInt64 = 0 + /// Last terminal element+content revision scheduled from an authoritative source. Shell prompt + /// heartbeats and OCR refreshes may repeat identical snapshots; this prevents useless reruns. + var lastScheduledTerminalSignature: String? /// Suppresses single-poll `Supported → Blocked → Supported` flicker on the same focused element /// so the overlay does not tear down and rebuild on every transient AX redraw. See /// `FocusCapabilityFlickerGate` for the rationale and the reproduction (Apple Calendar event diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 4e755b4a..7e4349a2 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -32,6 +32,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let activationIndicatorController: ActivationIndicatorController private let focusDebugOverlayController: FocusDebugOverlayController? + private let terminalAwareFocusModel: TerminalAwareFocusModel + private let terminalIntegrationService: TerminalIntegrationService + private let shellPromptGeometryCoordinator: ShellPromptGeometryCoordinator + private let tuiContextCoordinator: TuiContextCoordinator /// Retained for the app's lifetime because the environment owns its own `cancellables` (the only /// subscriptions wiring the focus-poll-interval setting and the global-toggle hotkey rebind to the /// runtime). If the environment deallocated when `init` returned, those subscriptions would be @@ -51,6 +55,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runtimeModel = environment.runtimeModel modelDownloadManager = environment.modelDownloadManager focusModel = environment.focusModel + terminalAwareFocusModel = environment.terminalAwareFocusModel + terminalIntegrationService = environment.terminalIntegrationService + shellPromptGeometryCoordinator = environment.shellPromptGeometryCoordinator + tuiContextCoordinator = environment.tuiContextCoordinator inputMonitor = environment.inputMonitor appUpdateManager = environment.appUpdateManager permissionGuidanceController = environment.permissionGuidanceController @@ -83,6 +91,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) + permissionManager.$screenRecordingGranted + .dropFirst() + .sink { [weak self] granted in + self?.handleTerminalScreenRecordingChange(granted: granted) + } + .store(in: &cancellables) + + suggestionSettings.$suggestInIntegratedTerminals + .dropFirst() + .removeDuplicates() + .sink { [weak self] enabled in + self?.updateTerminalIntegration(enabled: enabled) + } + .store(in: &cancellables) + suggestionSettings.$selectedEngine .dropFirst() .removeDuplicates() @@ -146,6 +169,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appUpdateManager.start() suggestionCoordinator.start() inlineCommandCoordinator.start() + if suggestionSettings.suggestInIntegratedTerminals { + _ = terminalIntegrationService.start() + } + tuiContextCoordinator.start() welcomeCoordinator.presentIfNeeded() welcomeCoordinator.presentPermissionReminderIfNeeded() didStartServices = true @@ -228,6 +255,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { focusDebugOverlayController?.hide() suggestionCoordinator.stop() inlineCommandCoordinator.stop() + tuiContextCoordinator.stop() + terminalIntegrationService.stop() + shellPromptGeometryCoordinator.invalidateAll() + terminalAwareFocusModel.clearTerminalContext() inputMonitor.stop() focusModel.stop() @@ -278,6 +309,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate { startRuntimeIfPreferredEngineRequiresIt() } + /// Starts/stops the IPC endpoint with the opt-in. Disabling also clears every derived terminal + /// artifact immediately so a previously verified prompt cannot stay active behind the toggle. + private func updateTerminalIntegration(enabled: Bool) { + guard didStartServices else { return } + if enabled { + _ = terminalIntegrationService.start() + tuiContextCoordinator.settingsOrPermissionChanged() + } else { + tuiContextCoordinator.settingsOrPermissionChanged() + terminalIntegrationService.stop() + shellPromptGeometryCoordinator.invalidateAll() + terminalAwareFocusModel.clearTerminalContext() + } + } + + private func handleTerminalScreenRecordingChange(granted: Bool) { + guard didStartServices else { return } + tuiContextCoordinator.settingsOrPermissionChanged() + guard !granted else { return } + shellPromptGeometryCoordinator.invalidateAll() + terminalAwareFocusModel.clearTerminalContext() + } + /// Xcode's app-hosted unit tests launch the real menu-bar app binary before loading the test /// bundle. Those tests instantiate focused services directly, so starting global taps, focus /// polling, Sparkle, and the llama runtime in the host process only adds side effects and can diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 99c2642d..0f6bd98e 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation import Logging @@ -15,6 +16,11 @@ final class CotabbyAppEnvironment { let runtimeModel: RuntimeBootstrapModel let modelDownloadManager: ModelDownloadManager let focusModel: FocusTrackingModel + /// Effective suggestion focus after arbitration between AX, shell-hook, and verified TUI data. + let terminalAwareFocusModel: TerminalAwareFocusModel + let terminalIntegrationService: TerminalIntegrationService + let shellPromptGeometryCoordinator: ShellPromptGeometryCoordinator + let tuiContextCoordinator: TuiContextCoordinator let inputMonitor: InputMonitor /// Temporarily pauses Calendar AX traversal only while its date/time editor is active. let calendarAccessibilityCaptureGuard: CalendarAccessibilityCaptureGuard @@ -107,25 +113,17 @@ final class CotabbyAppEnvironment { }, publishesPollingEvents: FocusDebugOverlayController.isEnabled ) + let terminalAwareFocusModel = TerminalAwareFocusModel(accessibilityModel: focusModel) + let terminalIntegrationService = TerminalIntegrationService() // The snapshot is poll-based, so after a fast app switch the closure may briefly // evaluate against the previous app's identity until the next AX poll fires. This // is the same race the downstream evaluator already has — not a new regression. inputMonitor.shouldProcessEventsProvider = { [weak focusModel] in - guard suggestionSettings.isGloballyEnabled, - !suggestionSettings.isTemporarilyPaused - else { return false } - guard let snapshot = focusModel?.snapshot else { return true } - if calendarAccessibilityCaptureGuard.shouldSuppressCapture( - for: snapshot.bundleIdentifier - ) { - return false - } - if TerminalAppDetector.isTerminal(bundleIdentifier: snapshot.bundleIdentifier) { return false } - if let bundleID = snapshot.bundleIdentifier, - suggestionSettings.isApplicationDisabled(bundleIdentifier: bundleID) { - return false - } - return true + Self.shouldProcessInputEvents( + snapshot: focusModel?.snapshot, + settings: suggestionSettings, + calendarGuard: calendarAccessibilityCaptureGuard + ) } let appUpdateManager = AppUpdateManager() let welcomeCoordinator = WelcomeCoordinator( @@ -160,6 +158,15 @@ final class CotabbyAppEnvironment { screenshotContextGenerator: screenshotContextGenerator, screenRecordingPermissionProvider: { permissionManager.screenRecordingGranted } ) + let terminalSubsystem = Self.makeTerminalSubsystem( + focusModel: focusModel, + effectiveFocusModel: terminalAwareFocusModel, + integrationService: terminalIntegrationService, + settings: suggestionSettings, + permissions: permissionManager + ) + let shellPromptGeometryCoordinator = terminalSubsystem.shellGeometry + let tuiContextCoordinator = terminalSubsystem.tuiContext let foundationModelEngine: any SuggestionGenerating #if canImport(FoundationModels) if #available(macOS 26.0, *) { @@ -245,7 +252,7 @@ final class CotabbyAppEnvironment { ) let suggestionCoordinator = SuggestionCoordinator( permissionManager: permissionManager, - focusModel: focusModel, + focusModel: terminalAwareFocusModel, inputMonitor: inputMonitor, overlayController: overlayController, suggestionInserter: suggestionInserter, @@ -263,6 +270,16 @@ final class CotabbyAppEnvironment { qualityMetricsStore: qualityMetricsStore ) + Self.wireTerminalSubsystem(TerminalWiring( + suggestionCoordinator: suggestionCoordinator, + focusModel: terminalAwareFocusModel, + integrationService: terminalIntegrationService, + shellGeometry: shellPromptGeometryCoordinator, + tuiContext: tuiContextCoordinator, + settings: suggestionSettings, + permissions: permissionManager + )) + // The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor, // focus model, and inserter, but owns its own trigger state machine and floating panel. let emojiPickerController = EmojiPickerController( @@ -307,6 +324,10 @@ final class CotabbyAppEnvironment { self.runtimeModel = runtimeModel self.modelDownloadManager = modelDownloadManager self.focusModel = focusModel + self.terminalAwareFocusModel = terminalAwareFocusModel + self.terminalIntegrationService = terminalIntegrationService + self.shellPromptGeometryCoordinator = shellPromptGeometryCoordinator + self.tuiContextCoordinator = tuiContextCoordinator self.inputMonitor = inputMonitor self.calendarAccessibilityCaptureGuard = calendarAccessibilityCaptureGuard self.appUpdateManager = appUpdateManager @@ -357,6 +378,263 @@ final class CotabbyAppEnvironment { observeOpenAICompatibleSelection() } + private static func shouldProcessInputEvents( + snapshot: FocusSnapshot?, + settings: SuggestionSettingsModel, + calendarGuard: CalendarAccessibilityCaptureGuard + ) -> Bool { + guard settings.isGloballyEnabled, !settings.isTemporarilyPaused else { return false } + guard let snapshot else { return true } + guard !calendarGuard.shouldSuppressCapture(for: snapshot.bundleIdentifier) else { + return false + } + if TerminalAppDetector.isTerminal(bundleIdentifier: snapshot.bundleIdentifier) + || snapshot.context?.isIntegratedTerminal == true { + // Input must reach the TUI observer before verified OCR owns focus. The suggestion + // evaluator remains fail-closed until an authoritative terminal role arrives. + return settings.suggestInIntegratedTerminals + } + if let bundleIdentifier = snapshot.bundleIdentifier, + settings.isApplicationDisabled(bundleIdentifier: bundleIdentifier) { + return false + } + return true + } + + /// Builds the screen-aware half of terminal support. IPC remains a separate long-lived service + /// because it starts and stops with the user's opt-in from `AppDelegate`. + private static func makeTerminalSubsystem( + focusModel: FocusTrackingModel, + effectiveFocusModel: TerminalAwareFocusModel, + integrationService: TerminalIntegrationService, + settings: SuggestionSettingsModel, + permissions: PermissionManager + ) -> TerminalSubsystem { + let screenshots = TerminalWindowScreenshotService() + let shellGeometry = ShellPromptGeometryCoordinator( + extractor: ScreenTextExtractor(), + captureProvider: { snapshot, pid in + let app = NSRunningApplication(processIdentifier: pid_t(pid)) + return try await screenshots.capture( + pid: pid, + bundleIdentifier: snapshot.terminalBundleIdentifier, + applicationName: app?.localizedName ?? snapshot.terminalBundleIdentifier, + preferredRegion: preferredTerminalRegion( + bundleIdentifier: snapshot.terminalBundleIdentifier, + pid: pid, + focusModel: focusModel + ) + ) + }, + latestSnapshotProvider: { [weak integrationService] in + integrationService?.latestSnapshot(for: $0) + }, + isEnabled: { + settings.suggestInIntegratedTerminals && permissions.screenRecordingGranted + } + ) + let tuiContext = TuiContextCoordinator( + reader: TuiContextReader(extractor: ScreenTextExtractor()), + candidateProvider: { currentTuiCandidate(focusModel: focusModel) }, + captureProvider: { candidate in + try await screenshots.capture( + pid: candidate.pid, + bundleIdentifier: candidate.bundleIdentifier, + applicationName: candidate.applicationName, + preferredRegion: candidate.preferredCaptureRegion + ) + }, + foregroundProcessProvider: { candidate in + let roots = [candidate.pid] + integrationService.activeShellPIDs( + forBundleIdentifier: candidate.bundleIdentifier + ) + return ProcessTreeInspector.subtreeProcessNames( + rootedAt: Array(Set(roots)), + includingRoots: true + ) + }, + isEnabled: { + settings.suggestInIntegratedTerminals && permissions.screenRecordingGranted + }, + isShellActivelyReporting: { + integrationService.isRecentlyReporting(forBundleIdentifier: $0) + }, + injectSnapshot: { effectiveFocusModel.publishTerminalContext($0) }, + clearInjection: { + effectiveFocusModel.clearTerminalContext( + ifRole: TerminalInputRole.claudeCodeTUI.rawValue + ) + } + ) + return TerminalSubsystem(shellGeometry: shellGeometry, tuiContext: tuiContext) + } + + private static func preferredTerminalRegion( + bundleIdentifier: String, + pid: Int32, + focusModel: FocusTrackingModel + ) -> CGRect? { + guard TerminalAppDetector.hostsEmbeddedTerminal(bundleIdentifier: bundleIdentifier), + let context = focusModel.snapshot.context, + context.bundleIdentifier == bundleIdentifier, + context.processIdentifier == pid, + context.isIntegratedTerminal, + let appKitFrame = context.inputFrameRect, + !appKitFrame.isEmpty else { return nil } + // Display-coordinate conversion is a Y-axis flip and therefore its own inverse. + return AXHelper.cocoaRect(fromAccessibilityRect: appKitFrame) + } + + private static func currentTuiCandidate( + focusModel: FocusTrackingModel + ) -> TuiContextCoordinator.Candidate? { + guard let app = NSWorkspace.shared.frontmostApplication, + let bundleIdentifier = app.bundleIdentifier, + TerminalAppDetector.isTerminalHost(bundleIdentifier: bundleIdentifier) + else { return nil } + if TerminalAppDetector.hostsEmbeddedTerminal(bundleIdentifier: bundleIdentifier), + focusModel.snapshot.context?.isIntegratedTerminal != true { + return nil + } + let pid = Int32(app.processIdentifier) + return TuiContextCoordinator.Candidate( + bundleIdentifier: bundleIdentifier, + applicationName: app.localizedName ?? bundleIdentifier, + pid: pid, + title: TerminalGeometryResolver.windowTitle(forPid: app.processIdentifier), + preferredCaptureRegion: preferredTerminalRegion( + bundleIdentifier: bundleIdentifier, + pid: pid, + focusModel: focusModel + ) + ) + } + + private static func wireTerminalSubsystem(_ wiring: TerminalWiring) { + let focusModel = wiring.focusModel + let shellGeometry = wiring.shellGeometry + let settings = wiring.settings + let permissions = wiring.permissions + let integrationService = wiring.integrationService + let suggestionCoordinator = wiring.suggestionCoordinator + let tuiContext = wiring.tuiContext + + integrationService.onSnapshotUpdate = { + handleShellSnapshot( + $0, + focusModel: focusModel, + shellGeometry: shellGeometry, + settings: settings, + permissions: permissions + ) + } + shellGeometry.onGeometryResolved = { + handleResolvedShellGeometry( + $0, + focusModel: focusModel, + settings: settings + ) + } + integrationService.onSessionChange = { [weak integrationService] in + guard let integrationService else { return } + // Session identities include nonces and are never reused. Clearing the small anchor + // cache on lifecycle changes prevents closed tabs from accumulating indefinitely; + // active shells re-anchor on their next buffer report. + shellGeometry.invalidateAll() + handleTerminalSessionChange( + focusModel: focusModel, + service: integrationService + ) + } + suggestionCoordinator.onTerminalInsertion = { [weak integrationService] context, text in + guard let integrationService else { return } + handleTerminalInsertion( + context, + insertedText: text, + service: integrationService + ) + } + suggestionCoordinator.onTerminalReplacement = { [weak integrationService] context, text in + guard let integrationService else { return } + handleTerminalReplacement( + context, + replacementText: text, + service: integrationService + ) + } + suggestionCoordinator.terminalIntegrationActiveProvider = { [weak focusModel] in + focusModel?.snapshot.context?.terminalInputRole != nil + } + suggestionCoordinator.tuiInputObserver = { [weak tuiContext] _ in tuiContext?.observeInput() } + } + + private static func handleShellSnapshot( + _ snapshot: TerminalFocusSnapshot, + focusModel: TerminalAwareFocusModel, + shellGeometry: ShellPromptGeometryCoordinator, + settings: SuggestionSettingsModel, + permissions: PermissionManager + ) { + guard settings.suggestInIntegratedTerminals, + permissions.screenRecordingGranted, + NSWorkspace.shared.frontmostApplication?.bundleIdentifier + == snapshot.terminalBundleIdentifier, + let pid = TerminalGeometryResolver.terminalAppPid( + forBundleIdentifier: snapshot.terminalBundleIdentifier + ) + else { return } + if let resolved = shellGeometry.resolve(snapshot, terminalPID: pid) { + focusModel.publishTerminalContext(TerminalFocusAdapter.adapt(resolved, terminalPid: pid)) + } + } + + private static func handleResolvedShellGeometry( + _ snapshot: TerminalFocusSnapshot, + focusModel: TerminalAwareFocusModel, + settings: SuggestionSettingsModel + ) { + guard settings.suggestInIntegratedTerminals, + NSWorkspace.shared.frontmostApplication?.bundleIdentifier + == snapshot.terminalBundleIdentifier, + let pid = TerminalGeometryResolver.terminalAppPid( + forBundleIdentifier: snapshot.terminalBundleIdentifier + ) else { return } + focusModel.publishTerminalContext(TerminalFocusAdapter.adapt(snapshot, terminalPid: pid)) + } + + private static func handleTerminalSessionChange( + focusModel: TerminalAwareFocusModel, + service: TerminalIntegrationService + ) { + guard let context = focusModel.snapshot.context, + context.terminalInputRole == .shell, + service.identity(forElementIdentifier: context.elementIdentifier) == nil + else { return } + focusModel.clearTerminalContext(ifRole: TerminalInputRole.shell.rawValue) + } + + private static func handleTerminalInsertion( + _ context: FocusedInputContext, + insertedText: String, + service: TerminalIntegrationService + ) { + guard context.terminalInputRole == .shell, + let identity = service.identity(forElementIdentifier: context.elementIdentifier) + else { return } + service.applyOptimisticInsertion(identity: identity, insertedText: insertedText) + } + + private static func handleTerminalReplacement( + _ context: FocusedInputContext, + replacementText: String, + service: TerminalIntegrationService + ) { + guard context.terminalInputRole == .shell, + let identity = service.identity(forElementIdentifier: context.elementIdentifier) + else { return } + service.applyOptimisticReplacement(identity: identity, replacementText: replacementText) + } + /// Applies the user's per-power-source profile (engine + model) whenever anything that could /// change the right answer changes: the power source, the feature toggle, either profile, or the /// installed-model list (so a profile referencing a still-loading model is honored once it @@ -476,6 +754,24 @@ final class CotabbyAppEnvironment { } } +/// Pair returned by the terminal composition helper. Both coordinators live for the app lifetime; +/// their individual start/stop behavior remains owned by `AppDelegate`. +private struct TerminalSubsystem { + let shellGeometry: ShellPromptGeometryCoordinator + let tuiContext: TuiContextCoordinator +} + +/// References needed to connect terminal source events after the suggestion coordinator exists. +private struct TerminalWiring { + let suggestionCoordinator: SuggestionCoordinator + let focusModel: TerminalAwareFocusModel + let integrationService: TerminalIntegrationService + let shellGeometry: ShellPromptGeometryCoordinator + let tuiContext: TuiContextCoordinator + let settings: SuggestionSettingsModel + let permissions: PermissionManager +} + private extension String { var nonEmpty: String? { isEmpty ? nil : self } } diff --git a/Cotabby/Models/FocusModels.swift b/Cotabby/Models/FocusModels.swift index 89bf5a3c..b83953c7 100644 --- a/Cotabby/Models/FocusModels.swift +++ b/Cotabby/Models/FocusModels.swift @@ -219,6 +219,13 @@ nonisolated struct FocusedInputSnapshot: Equatable { /// compiling unchanged. let fieldPlaceholder: String? + /// Shell-only metadata supplied by the cooperative hook. Nil for normal AX and TUI inputs. + let terminalWorkingDirectory: String? + let terminalTTY: String? + /// Monotonic revision from the authoritative terminal source. Content signatures still catch + /// text changes; this also distinguishes source updates whose visible text happens to match. + let sourceRevision: UInt64? + /// Explicit initializer keeps `focusChangeSequence` immutable while preserving the old /// memberwise-call ergonomics for tests that do not care about focus identity. /// @@ -248,7 +255,10 @@ nonisolated struct FocusedInputSnapshot: Equatable { focusedURLString: String? = nil, resolvedFieldStyle: ResolvedFieldStyle? = nil, windowTitle: String? = nil, - fieldPlaceholder: String? = nil + fieldPlaceholder: String? = nil, + terminalWorkingDirectory: String? = nil, + terminalTTY: String? = nil, + sourceRevision: UInt64? = nil ) { self.applicationName = applicationName self.bundleIdentifier = bundleIdentifier @@ -273,6 +283,9 @@ nonisolated struct FocusedInputSnapshot: Equatable { self.resolvedFieldStyle = resolvedFieldStyle self.windowTitle = windowTitle self.fieldPlaceholder = fieldPlaceholder + self.terminalWorkingDirectory = terminalWorkingDirectory + self.terminalTTY = terminalTTY + self.sourceRevision = sourceRevision } var identity: FocusedInputIdentity { @@ -282,6 +295,44 @@ nonisolated struct FocusedInputSnapshot: Equatable { ) } + var terminalInputRole: TerminalInputRole? { + TerminalInputRole(accessibilityRole: role) + } + + /// Returns the same immutable observation tagged with a different focus-session sequence. + /// Terminal source arbitration uses this to keep external identities disjoint from AX polling + /// without making either adapter hand-copy every field. + func withFocusChangeSequence(_ sequence: UInt64) -> FocusedInputSnapshot { + FocusedInputSnapshot( + applicationName: applicationName, + bundleIdentifier: bundleIdentifier, + processIdentifier: processIdentifier, + elementIdentifier: elementIdentifier, + role: role, + subrole: subrole, + caretRect: caretRect, + inputFrameRect: inputFrameRect, + caretSource: caretSource, + caretQuality: caretQuality, + observedCharWidth: observedCharWidth, + observedContentEdges: observedContentEdges, + precedingText: precedingText, + trailingText: trailingText, + selection: selection, + isSecure: isSecure, + isIntegratedTerminal: isIntegratedTerminal, + isWebContentField: isWebContentField, + focusChangeSequence: sequence, + focusedURLString: focusedURLString, + resolvedFieldStyle: resolvedFieldStyle, + windowTitle: windowTitle, + fieldPlaceholder: fieldPlaceholder, + terminalWorkingDirectory: terminalWorkingDirectory, + terminalTTY: terminalTTY, + sourceRevision: sourceRevision + ) + } + /// The signature lets later pipeline stages detect whether a completion result is stale. /// This is the same idea you would use in a React app with a derived cache key. /// Content-only fingerprint for staleness detection. Deliberately excludes `elementIdentifier` @@ -293,7 +344,8 @@ nonisolated struct FocusedInputSnapshot: Equatable { String(selection.length), precedingText, trailingText, - isSecure ? "secure" : "plain" + isSecure ? "secure" : "plain", + sourceRevision.map(String.init) ?? "no-source-revision" ].joined(separator: "::") } } diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index dfc44b15..a316e78a 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -242,6 +242,9 @@ struct FocusedInputContext: Equatable, Sendable { let fieldPlaceholder: String? let focusedURLString: String? let isIntegratedTerminal: Bool + let terminalWorkingDirectory: String? + let terminalTTY: String? + let sourceRevision: UInt64? /// 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. @@ -271,6 +274,9 @@ struct FocusedInputContext: Equatable, Sendable { fieldPlaceholder = snapshot.fieldPlaceholder focusedURLString = snapshot.focusedURLString isIntegratedTerminal = snapshot.isIntegratedTerminal + terminalWorkingDirectory = snapshot.terminalWorkingDirectory + terminalTTY = snapshot.terminalTTY + sourceRevision = snapshot.sourceRevision focusChangeSequence = snapshot.focusChangeSequence self.generation = generation } @@ -282,6 +288,10 @@ struct FocusedInputContext: Equatable, Sendable { ) } + var terminalInputRole: TerminalInputRole? { + TerminalInputRole(accessibilityRole: role) + } + /// 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. @@ -312,7 +322,8 @@ struct FocusedInputContext: Equatable, Sendable { String(selection.length), precedingText, trailingText, - isSecure ? "secure" : "plain" + isSecure ? "secure" : "plain", + sourceRevision.map(String.init) ?? "no-source-revision" ].joined(separator: "::") } } @@ -327,6 +338,9 @@ struct FocusedInputContext: Equatable, Sendable { enum SuggestionKind: Equatable, Sendable { case continuation case correction(typoWord: String) + /// Replaces an unchanged plain-English shell buffer with one generated command. The original + /// line travels with the session so acceptance can fail closed if the user edits before Tab. + case terminalCommandReplacement(originalText: String) var isCorrection: Bool { if case .correction = self { @@ -334,6 +348,26 @@ enum SuggestionKind: Equatable, Sendable { } return false } + + var isReplacement: Bool { + switch self { + case .correction, .terminalCommandReplacement: + return true + case .continuation: + return false + } + } +} + +/// Tells prompt renderers and the result pipeline whether output extends or replaces the prefix. +enum SuggestionRequestMode: Equatable, Sendable { + case continuation + case terminalCommandReplacement(originalText: String) + + var isTerminalCommandReplacement: Bool { + if case .terminalCommandReplacement = self { return true } + return false + } } /// One generation request sent from the coordinator into the suggestion engine. @@ -393,6 +427,7 @@ struct SuggestionRequest: Equatable, Sendable { /// `RequestID.generate()` in `SuggestionRequestFactory`. Defaulted in the init so test fixtures /// that build requests directly do not need to change. let requestID: String + let mode: SuggestionRequestMode init( context: FocusedInputContext, @@ -416,7 +451,8 @@ struct SuggestionRequest: Equatable, Sendable { visualContextSummary: String?, surfaceContext: SurfaceContext? = nil, isMultiLineEnabled: Bool, - requestID: String = "req_unknown" + requestID: String = "req_unknown", + mode: SuggestionRequestMode = .continuation ) { self.context = context self.prefixText = prefixText @@ -440,6 +476,7 @@ struct SuggestionRequest: Equatable, Sendable { self.surfaceContext = surfaceContext self.isMultiLineEnabled = isMultiLineEnabled self.requestID = requestID + self.mode = mode } } @@ -454,19 +491,24 @@ struct SuggestionResult: Equatable, Sendable { /// 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? + /// Copied from the request so the coordinator can choose replacement acceptance after the + /// asynchronous engine boundary without re-classifying a potentially changed live buffer. + let mode: SuggestionRequestMode init( generation: UInt64, rawText: String, text: String, latency: TimeInterval, - suppressionReason: String? = nil + suppressionReason: String? = nil, + mode: SuggestionRequestMode = .continuation ) { self.generation = generation self.rawText = rawText self.text = text self.latency = latency self.suppressionReason = suppressionReason + self.mode = mode } } diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 3d86138c..083cd6b2 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -17,9 +17,8 @@ struct SuggestionSettingsData: Equatable { var showIndicator: Bool var showAcceptanceHint: Bool var disabledAppRules: [DisabledApplicationRule] - /// When false (the default), ghost text is suppressed in integrated terminals (VS Code / Cursor - /// xterm.js surfaces); a terminal's own completion/history conflicts with autocomplete and ghost - /// text overlaps command output. Power users can opt back in. + /// Master opt-in for source-verified terminal autocomplete. Enabling alone does not allow raw + /// terminal AX text; a live shell hook or verified Claude Code OCR snapshot is also required. var suggestInIntegratedTerminals: Bool var customSuggestionTextColorHex: String? var ghostTextOpacity: Double diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 8f2b27f2..b43256e3 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -41,9 +41,9 @@ final class SuggestionSettingsModel: ObservableObject { /// Whether the keycap hint (the small pill that teaches the accept key) is drawn after ghost text. @Published private(set) var showAcceptanceHint: Bool @Published private(set) var disabledAppRules: [DisabledApplicationRule] - /// Whether Cotabby should suggest inside integrated terminals (VS Code / Cursor xterm.js - /// surfaces). Off by default: a terminal's own completion/history conflicts with ghost text and - /// overlaps command output. Power users who want it can opt back in from the Apps settings pane. + /// Master opt-in for source-verified shell and Claude Code terminal autocomplete. Off by + /// default; the runtime still requires an active hook/TUI source before making a terminal field + /// eligible, so this preference can never authorize opaque AX output by itself. @Published private(set) var suggestInIntegratedTerminals: Bool @Published private(set) var customSuggestionTextColorHex: String? @Published private(set) var ghostTextOpacity: Double diff --git a/Cotabby/Models/SuggestionSubsystemContracts.swift b/Cotabby/Models/SuggestionSubsystemContracts.swift index ecb1fc23..7288fcae 100644 --- a/Cotabby/Models/SuggestionSubsystemContracts.swift +++ b/Cotabby/Models/SuggestionSubsystemContracts.swift @@ -206,10 +206,36 @@ protocol SuggestionInserting: AnyObject { func insert(_ suggestion: String) -> Bool + /// Selects the transport for one validated acceptance. Terminal/TUI inputs require paste so + /// bracketed-paste-aware editors receive literal text; the mode is passed per call so it cannot + /// leak into a later acceptance in a normal app after focus changes. + func insert(_ suggestion: String, mode: SuggestionInsertionMode) -> Bool + /// Deletes `deletingUTF16Count` already-typed units and types `text` in one suppressed synthetic /// burst. The correction-acceptance path uses this to swap a typo'd word for the corrected word. /// `SuggestionInserter` already implements it (the emoji picker shares the same primitive). func replace(deletingUTF16Count: Int, with text: String) -> Bool + + /// Deletes a complete shell line by character count, then inserts the reviewed replacement via + /// terminal paste. Kept separate from ordinary typo replacement because terminal protocols do + /// not reliably accept a synthetic Unicode insertion event. + func replaceTerminalLine(deletingCharacterCount: Int, with text: String) -> Bool +} + +extension SuggestionInserting { + /// Test and alternate inserters can reuse their normal replace primitive. Production overrides + /// this with a terminal-paste implementation. + func replaceTerminalLine(deletingCharacterCount: Int, with text: String) -> Bool { + replace(deletingUTF16Count: deletingCharacterCount, with: text) + } +} + +/// Destination-specific insertion transport chosen after acceptance validates the live context. +nonisolated enum SuggestionInsertionMode: Equatable, Sendable { + /// Preserve Cotabby's normal IME-aware and opt-in long-text strategy selection. + case automatic + /// Commit through the clipboard paste path used by shell prompts and terminal TUIs. + case terminalPaste } /// The emoji picker's slice of the inserter: replace a run of already-typed characters (the literal diff --git a/Cotabby/Models/TerminalAwareFocusModel.swift b/Cotabby/Models/TerminalAwareFocusModel.swift new file mode 100644 index 00000000..093cc133 --- /dev/null +++ b/Cotabby/Models/TerminalAwareFocusModel.swift @@ -0,0 +1,146 @@ +import Combine +import Foundation + +/// Publishes the focus source the suggestion pipeline should trust right now. +/// +/// `FocusTrackingModel` remains the owner of raw Accessibility state. Terminal shells and TUIs, +/// however, have an authoritative text source outside AX. This model arbitrates between those +/// sources without teaching the AX tracker about sockets, OCR, or shell sessions. The suggestion +/// coordinator observes this model, while menus, emoji/macros, and diagnostics can keep observing +/// the unmodified AX model. +/// +/// A terminal override survives same-app unsupported AX polls (the normal terminal case). It is +/// released immediately when the user changes apps, or when a supported non-terminal field inside +/// an embedded host such as VS Code reclaims focus. This prevents a stale terminal buffer from +/// shadowing the editor that shares its process. +@MainActor +final class TerminalAwareFocusModel: ObservableObject, SuggestionFocusProviding { + @Published private(set) var snapshot: FocusSnapshot + + /// Lets the terminal coordinators cancel source-specific work when AX reclaims effective focus. + var onTerminalOverrideCleared: (() -> Void)? + + private struct Override { + let bundleIdentifier: String + let elementIdentifier: String + let role: String + } + + private let accessibilityModel: FocusTrackingModel + private var activeOverride: Override? + private var injectedFocusSequence: UInt64 = 0 + private var cancellables = Set() + + init(accessibilityModel: FocusTrackingModel) { + self.accessibilityModel = accessibilityModel + snapshot = accessibilityModel.snapshot + + accessibilityModel.snapshotPublisher + .sink { [weak self] snapshot in + self?.applyAccessibilitySnapshot(snapshot) + } + .store(in: &cancellables) + } + + /// Makes a shell- or OCR-sourced input the coordinator's effective focus. + /// + /// Terminal focus sequences occupy the upper half of `UInt64`, keeping them disjoint from the + /// AX tracker's ordinary monotonic counter for any realistic app lifetime. The sequence changes + /// only when the terminal input identity changes; buffer and geometry updates inside the same + /// shell/TUI retain one focus session and use content/source revisions for freshness. + func publishTerminalContext(_ context: FocusedInputSnapshot) { + let identityChanged = activeOverride?.elementIdentifier != context.elementIdentifier + || activeOverride?.role != context.role + if activeOverride == nil || identityChanged { + injectedFocusSequence &+= 1 + } + + let taggedContext = context.withFocusChangeSequence( + (UInt64(1) << 63) | injectedFocusSequence + ) + activeOverride = Override( + bundleIdentifier: taggedContext.bundleIdentifier, + elementIdentifier: taggedContext.elementIdentifier, + role: taggedContext.role + ) + + publishIfChanged( + FocusSnapshot( + applicationName: taggedContext.applicationName, + bundleIdentifier: taggedContext.bundleIdentifier, + capability: .supported, + context: taggedContext, + inspection: nil + ) + ) + } + + /// Releases any terminal override and immediately restores the latest real AX snapshot. + /// `role` scopes teardown so a stale TUI callback cannot accidentally clear a newer shell source. + func clearTerminalContext(ifRole role: String? = nil) { + guard let activeOverride else { return } + if let role, activeOverride.role != role { + return + } + + self.activeOverride = nil + publishIfChanged(accessibilityModel.snapshot) + onTerminalOverrideCleared?() + } + + var hasTerminalOverride: Bool { + activeOverride != nil + } + + var terminalOverrideRole: String? { + activeOverride?.role + } + + var millisecondsSinceLastCapture: Int? { + accessibilityModel.millisecondsSinceLastCapture + } + + func refreshNow() { + accessibilityModel.refreshNow() + } + + func invalidateTransientCaretCaches() { + accessibilityModel.invalidateTransientCaretCaches() + } + + var snapshotPublisher: AnyPublisher { + $snapshot.eraseToAnyPublisher() + } + + private func applyAccessibilitySnapshot(_ accessibilitySnapshot: FocusSnapshot) { + guard let activeOverride else { + publishIfChanged(accessibilitySnapshot) + return + } + + guard accessibilitySnapshot.bundleIdentifier == activeOverride.bundleIdentifier else { + self.activeOverride = nil + publishIfChanged(accessibilitySnapshot) + onTerminalOverrideCleared?() + return + } + + // Editors and command palettes inside embedded-terminal hosts share the terminal's bundle + // identifier. A supported, non-xterm AX field is positive evidence that the user left the + // terminal pane, so it must reclaim focus immediately. Dedicated terminal apps never reach + // this branch because their opaque text surfaces remain unsupported. + if TerminalAppDetector.hostsEmbeddedTerminal( + bundleIdentifier: activeOverride.bundleIdentifier + ), accessibilitySnapshot.capability == .supported, + accessibilitySnapshot.context?.isIntegratedTerminal != true { + self.activeOverride = nil + publishIfChanged(accessibilitySnapshot) + onTerminalOverrideCleared?() + } + } + + private func publishIfChanged(_ next: FocusSnapshot) { + guard next != snapshot else { return } + snapshot = next + } +} diff --git a/Cotabby/Models/TerminalFocusModels.swift b/Cotabby/Models/TerminalFocusModels.swift new file mode 100644 index 00000000..dafc01c1 --- /dev/null +++ b/Cotabby/Models/TerminalFocusModels.swift @@ -0,0 +1,240 @@ +import CoreGraphics +import Foundation + +/// Shells supported by Cotabby's cooperative prompt integration. +nonisolated enum ShellType: String, Codable, Equatable, Sendable { + case zsh + case bash + case fish +} + +/// Accessibility-role values reserved for authoritative non-AX terminal input sources. +/// +/// These strings deliberately cannot collide with native roles such as `AXTextArea`. Keeping the +/// discriminator in one value type prevents coordinator, prompt, and insertion code from growing +/// parallel string-literal checks. +nonisolated enum TerminalInputRole: String, Equatable, Sendable { + case shell = "AXCotabbyTerminalShell" + case claudeCodeTUI = "AXCotabbyClaudeCodeTUI" + + init?(accessibilityRole: String) { + self.init(rawValue: accessibilityRole) + } + + static func isTerminalRole(_ role: String) -> Bool { + Self(accessibilityRole: role) != nil + } +} + +/// Stable identity for one sourced shell session. +/// +/// PID alone is insufficient because macOS reuses process identifiers. The hook creates a short +/// random nonce once when sourced, so a restarted shell cannot inherit another session's buffered +/// suggestion even if the operating system later gives it the same PID. +nonisolated struct TerminalSessionIdentity: Codable, Equatable, Hashable, Sendable { + let shellPid: Int32 + let nonce: String + + var elementIdentifier: String { + "terminal-shell-\(shellPid)-\(nonce)" + } +} + +/// One newline-delimited JSON frame sent by a shell hook. +nonisolated struct TerminalIpcMessage: Codable, Equatable, Sendable { + enum MessageType: String, Codable, Sendable { + case buffer + case disconnect + } + + let type: MessageType + let text: String? + /// Raw shell cursor unit: UTF-8 bytes for bash, characters for zsh/fish. + let cursor: Int? + let shell: ShellType? + let terminal: String? + let pid: Int32? + let session: String? + let tty: String? + let cwd: String? + let revision: UInt64? +} + +/// Authoritative editable state for one shell prompt. +/// +/// Cursor offsets are normalized to Swift `Character` units at the IPC boundary. That invariant is +/// load-bearing for Unicode: geometry, text splitting, and optimistic paste echo all consume this +/// value and must never reinterpret bash's byte offset independently. +nonisolated struct TerminalFocusSnapshot: Equatable, Sendable { + let sessionIdentity: TerminalSessionIdentity + let commandBuffer: String + let cursorCharacterOffset: Int + let shellType: ShellType + let terminalBundleIdentifier: String + let tty: String? + let workingDirectory: String? + let sourceRevision: UInt64 + let timestamp: Date + + /// Geometry in AppKit bottom-left screen coordinates after the service boundary converts it. + let terminalWindowFrame: CGRect? + let estimatedCursorRect: CGRect? + let promptLineRect: CGRect? + let observedCellWidth: CGFloat? + + init( + sessionIdentity: TerminalSessionIdentity, + commandBuffer: String, + cursorCharacterOffset: Int, + shellType: ShellType, + terminalBundleIdentifier: String, + tty: String?, + workingDirectory: String?, + sourceRevision: UInt64, + timestamp: Date = Date(), + terminalWindowFrame: CGRect? = nil, + estimatedCursorRect: CGRect? = nil, + promptLineRect: CGRect? = nil, + observedCellWidth: CGFloat? = nil + ) { + self.sessionIdentity = sessionIdentity + self.commandBuffer = commandBuffer + self.cursorCharacterOffset = min(max(cursorCharacterOffset, 0), commandBuffer.count) + self.shellType = shellType + self.terminalBundleIdentifier = terminalBundleIdentifier + self.tty = tty + self.workingDirectory = workingDirectory + self.sourceRevision = sourceRevision + self.timestamp = timestamp + self.terminalWindowFrame = terminalWindowFrame + self.estimatedCursorRect = estimatedCursorRect + self.promptLineRect = promptLineRect + self.observedCellWidth = observedCellWidth + } + + var precedingText: String { + let index = commandBuffer.index( + commandBuffer.startIndex, + offsetBy: cursorCharacterOffset, + limitedBy: commandBuffer.endIndex + ) ?? commandBuffer.endIndex + return String(commandBuffer[.. TerminalFocusSnapshot { + guard !insertedText.isEmpty else { return self } + return TerminalFocusSnapshot( + sessionIdentity: sessionIdentity, + commandBuffer: precedingText + insertedText + trailingText, + cursorCharacterOffset: cursorCharacterOffset + insertedText.count, + shellType: shellType, + terminalBundleIdentifier: terminalBundleIdentifier, + tty: tty, + workingDirectory: workingDirectory, + sourceRevision: sourceRevision &+ 1, + timestamp: Date(), + terminalWindowFrame: terminalWindowFrame, + estimatedCursorRect: estimatedCursorRect, + promptLineRect: promptLineRect, + observedCellWidth: observedCellWidth + ) + } + + /// Mirrors a successful whole-line translation until the shell hook publishes ground truth. + /// Geometry stays anchored to the same prompt row; only the buffer, cursor, timestamp, and + /// source revision advance. + func replacingCommandBuffer(with replacement: String) -> TerminalFocusSnapshot { + TerminalFocusSnapshot( + sessionIdentity: sessionIdentity, + commandBuffer: replacement, + cursorCharacterOffset: replacement.count, + shellType: shellType, + terminalBundleIdentifier: terminalBundleIdentifier, + tty: tty, + workingDirectory: workingDirectory, + sourceRevision: sourceRevision &+ 1, + timestamp: Date(), + terminalWindowFrame: terminalWindowFrame, + estimatedCursorRect: estimatedCursorRect, + promptLineRect: promptLineRect, + observedCellWidth: observedCellWidth + ) + } + + func withGeometry( + windowFrame: CGRect?, + cursorRect: CGRect, + promptLineRect: CGRect, + observedCellWidth: CGFloat + ) -> TerminalFocusSnapshot { + TerminalFocusSnapshot( + sessionIdentity: sessionIdentity, + commandBuffer: commandBuffer, + cursorCharacterOffset: cursorCharacterOffset, + shellType: shellType, + terminalBundleIdentifier: terminalBundleIdentifier, + tty: tty, + workingDirectory: workingDirectory, + sourceRevision: sourceRevision, + timestamp: timestamp, + terminalWindowFrame: windowFrame ?? terminalWindowFrame, + estimatedCursorRect: cursorRect, + promptLineRect: promptLineRect, + observedCellWidth: observedCellWidth + ) + } + + /// Converts the shell-specific wire cursor to the model's single character-offset invariant. + static func normalizedCharacterOffset(rawOffset: Int, text: String, shell: ShellType) -> Int { + guard rawOffset > 0 else { return 0 } + switch shell { + case .zsh, .fish: + return min(rawOffset, text.count) + case .bash: + let utf8 = text.utf8 + let clamped = min(rawOffset, utf8.count) + let utf8Index = utf8.index(utf8.startIndex, offsetBy: clamped) + guard let stringIndex = utf8Index.samePosition(in: text) else { + // A byte offset inside a multi-byte scalar is invalid. Move left to the nearest + // character boundary rather than splitting the scalar or crashing. + var candidate = clamped + while candidate > 0 { + candidate -= 1 + let index = utf8.index(utf8.startIndex, offsetBy: candidate) + if let boundary = index.samePosition(in: text) { + return text.distance(from: text.startIndex, to: boundary) + } + } + return 0 + } + return text.distance(from: text.startIndex, to: stringIndex) + } + } +} + +/// Mutable lifecycle record owned by `TerminalIntegrationService` on the main actor. +nonisolated struct TerminalSession: Equatable, Sendable { + let identity: TerminalSessionIdentity + var shellType: ShellType + let terminalBundleIdentifier: String + var tty: String? + let connectedAt: Date + var lastMessageAt: Date + /// Last revision received from the shell hook. Optimistic acceptance echoes intentionally do + /// not advance this counter, otherwise the hook's matching ground-truth revision would be + /// mistaken for a replay and discarded. + var lastWireRevision: UInt64 + var latestSnapshot: TerminalFocusSnapshot? +} diff --git a/Cotabby/Models/TerminalIntegrationPaths.swift b/Cotabby/Models/TerminalIntegrationPaths.swift new file mode 100644 index 00000000..9e2150d1 --- /dev/null +++ b/Cotabby/Models/TerminalIntegrationPaths.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Bundle-aware filesystem layout for terminal integration. +/// +/// Cotabby and Cotabby Dev are intentionally allowed to run side-by-side. Giving each product its +/// own socket and installed-hook directory prevents one process from unlinking or updating the +/// other's endpoint. Hooks live in Application Support, while the socket uses the user's private +/// temporary directory so its path stays below Darwin's 104-byte `sockaddr_un.sun_path` limit. +nonisolated struct TerminalIntegrationPaths: Equatable, Sendable { + let productIdentifier: String + let rootDirectory: URL + let socketURL: URL + let hooksDirectory: URL + + init( + bundleIdentifier: String, + applicationSupportRoot: URL, + socketRoot: URL = FileManager.default.temporaryDirectory + ) { + let safeIdentifier = Self.sanitizedPathComponent(bundleIdentifier) + productIdentifier = safeIdentifier + rootDirectory = applicationSupportRoot + .appendingPathComponent("Cotabby", isDirectory: true) + .appendingPathComponent("TerminalIntegration", isDirectory: true) + .appendingPathComponent(safeIdentifier, isDirectory: true) + socketURL = socketRoot + .appendingPathComponent("cotabby-terminal", isDirectory: true) + .appendingPathComponent(Self.socketFilename(for: safeIdentifier), isDirectory: false) + hooksDirectory = rootDirectory.appendingPathComponent("shell-integration", isDirectory: true) + } + + static func current( + bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.jacobfu.tabby", + fileManager: FileManager = .default + ) -> TerminalIntegrationPaths { + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? fileManager.homeDirectoryForCurrentUser + return TerminalIntegrationPaths( + bundleIdentifier: bundleIdentifier, + applicationSupportRoot: appSupport, + socketRoot: fileManager.temporaryDirectory + ) + } + + func hookURL(for shell: ShellType) -> URL { + hooksDirectory.appendingPathComponent("cotabby.\(shell.rawValue)", isDirectory: false) + } + + private static func sanitizedPathComponent(_ raw: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let scalars = raw.unicodeScalars.map { allowed.contains($0) ? Character(String($0)) : "_" } + let result = String(scalars) + return result.isEmpty ? "cotabby" : String(result.prefix(120)) + } + + private static func socketFilename(for productIdentifier: String) -> String { + switch productIdentifier { + case "com.jacobfu.tabby": "cotabby.sock" + case "com.jacobfu.tabby.dev": "cotabby-dev.sock" + default: "\(String(productIdentifier.prefix(32))).sock" + } + } +} diff --git a/Cotabby/Services/Runtime/FoundationModelSuggestionEngine.swift b/Cotabby/Services/Runtime/FoundationModelSuggestionEngine.swift index fae711e0..e32b26a1 100644 --- a/Cotabby/Services/Runtime/FoundationModelSuggestionEngine.swift +++ b/Cotabby/Services/Runtime/FoundationModelSuggestionEngine.swift @@ -121,7 +121,8 @@ final class FoundationModelSuggestionEngine { generation: request.generation, rawText: rawSuggestion, text: partialNormalized, - latency: Date().timeIntervalSince(startTime) + latency: Date().timeIntervalSince(startTime), + mode: request.mode )) } } @@ -176,7 +177,8 @@ final class FoundationModelSuggestionEngine { rawText: rawSuggestion, text: normalizedSuggestion, latency: latency, - suppressionReason: normalization.suppression?.rawValue + suppressionReason: normalization.suppression?.rawValue, + mode: request.mode ) } catch is CancellationError { CotabbyLogger.suggestion.debug("Foundation model generation cancelled", metadata: baseMetadata) diff --git a/Cotabby/Services/Runtime/LlamaSuggestionEngine.swift b/Cotabby/Services/Runtime/LlamaSuggestionEngine.swift index eb5c4454..3ec097c1 100644 --- a/Cotabby/Services/Runtime/LlamaSuggestionEngine.swift +++ b/Cotabby/Services/Runtime/LlamaSuggestionEngine.swift @@ -138,7 +138,8 @@ final class LlamaSuggestionEngine { generation: request.generation, rawText: raw, text: normalized, - latency: Date().timeIntervalSince(startTime) + latency: Date().timeIntervalSince(startTime), + mode: request.mode )) } } @@ -202,7 +203,8 @@ final class LlamaSuggestionEngine { rawText: rawSuggestion, text: normalizedSuggestion, latency: latency, - suppressionReason: normalization.suppression?.rawValue + suppressionReason: normalization.suppression?.rawValue, + mode: request.mode ) } catch is CancellationError { CotabbyLogger.suggestion.debug("Llama generation cancelled", metadata: baseMetadata) diff --git a/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift b/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift index 33e2f703..1160772d 100644 --- a/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift +++ b/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift @@ -45,7 +45,8 @@ final class OpenAICompatibleSuggestionEngine: SuggestionGenerating { generation: request.generation, rawText: cumulativeRaw, text: normalized, - latency: Date().timeIntervalSince(startTime) + latency: Date().timeIntervalSince(startTime), + mode: request.mode )) } } else { @@ -105,7 +106,8 @@ final class OpenAICompatibleSuggestionEngine: SuggestionGenerating { rawText: raw, text: normalization.text, latency: latency, - suppressionReason: normalization.suppression?.rawValue + suppressionReason: normalization.suppression?.rawValue, + mode: request.mode ) } catch is CancellationError { CotabbyLogger.suggestion.debug("Endpoint generation cancelled", metadata: metadata) diff --git a/Cotabby/Services/Suggestion/ContextBuffer.swift b/Cotabby/Services/Suggestion/ContextBuffer.swift index a6fde0e2..2077a56a 100644 --- a/Cotabby/Services/Suggestion/ContextBuffer.swift +++ b/Cotabby/Services/Suggestion/ContextBuffer.swift @@ -11,6 +11,8 @@ final class ContextBuffer { private var lastSignature: String? private var lastProcessIdentifier: Int32? + private var lastElementIdentifier: String? + private var lastRole: String? private var nextGeneration: UInt64 = 0 /// Converts the latest focus snapshot into a stable context and bumps the generation when @@ -22,12 +24,20 @@ final class ContextBuffer { // `processIdentifier` instead of `elementIdentifier` here because Chrome recycles // AX node tokens between polls, making CFHash-based identity unstable. Intra-process // field switches are detected by the content signature changing. - if snapshot.processIdentifier != lastProcessIdentifier || signature != lastSignature { + let terminalIdentityChanged = ( + snapshot.terminalInputRole != nil + || lastRole.map(TerminalInputRole.isTerminalRole) == true + ) && snapshot.elementIdentifier != lastElementIdentifier + if snapshot.processIdentifier != lastProcessIdentifier + || terminalIdentityChanged + || signature != lastSignature { nextGeneration &+= 1 } lastProcessIdentifier = snapshot.processIdentifier lastSignature = signature + lastElementIdentifier = snapshot.elementIdentifier + lastRole = snapshot.role let context = FocusedInputContext(snapshot: snapshot, generation: nextGeneration) currentContext = context @@ -38,6 +48,8 @@ final class ContextBuffer { func clear() { lastSignature = nil lastProcessIdentifier = nil + lastElementIdentifier = nil + lastRole = nil currentContext = nil nextGeneration &+= 1 } diff --git a/Cotabby/Services/Suggestion/SuggestionInserter.swift b/Cotabby/Services/Suggestion/SuggestionInserter.swift index 1c0af40d..efa0e70d 100644 --- a/Cotabby/Services/Suggestion/SuggestionInserter.swift +++ b/Cotabby/Services/Suggestion/SuggestionInserter.swift @@ -30,6 +30,14 @@ final class SuggestionInserter { private var pendingPasteboardRestore: DispatchWorkItem? private var savedClipboardForRestore: [[NSPasteboard.PasteboardType: Data]]? + /// The clipboard state captured while preparing a paste. Keeping the snapshot and its change + /// count together lets both normal paste insertion and terminal whole-line replacement share + /// the same restore guarantees without sharing how they trigger the actual paste command. + private struct PreparedPasteboard { + let savedClipboard: [[NSPasteboard.PasteboardType: Data]] + let expectedChangeCount: Int + } + /// Paste menu items located by `AXHelper.pasteMenuItem(forApplicationPID:)`, cached per app so /// repeat accepts skip the menu-bar walk. A cached item is validated by its `AXPress` result; /// a failure (menu rebuilt, app quit) evicts and re-walks once. @@ -62,6 +70,16 @@ final class SuggestionInserter { /// Posts a Unicode keydown/keyup pair for the accepted suggestion and reports any insertion failure. func insert(_ suggestion: String) -> Bool { + insert(suggestion, mode: .automatic) + } + + /// Inserts using a transport selected for the validated focused-input source. + /// + /// Terminal editors are intentionally paste-only: a single synthetic Unicode event is not + /// interpreted reliably by terminal keyboard protocols, while Cmd-V enters the terminal's + /// bracketed-paste path. Keeping this choice on the call prevents terminal state from becoming + /// mutable process-global mode that could survive an app switch. + func insert(_ suggestion: String, mode: SuggestionInsertionMode) -> Bool { let normalized = suggestion.replacingOccurrences(of: "\r", with: "") guard !normalized.isEmpty else { lastErrorMessage = "Suggestion was empty." @@ -69,6 +87,17 @@ final class SuggestionInserter { return false } + if mode == .terminalPaste { + guard insertViaPaste(normalized) else { + lastErrorMessage = "Unable to paste the suggestion into the terminal." + CotabbyLogger.suggestion.error("Terminal insertion failed: clipboard paste could not be committed") + return false + } + lastErrorMessage = nil + CotabbyLogger.suggestion.debug("Inserted \(normalized.count) characters via terminal clipboard paste") + return true + } + // A composing IME (Japanese kana, Chinese pinyin, Korean hangul, ...) is active. A synthetic // Unicode keystroke would be re-absorbed into composition by the input method (the placeholder // keycode-0 event re-enters the IME) instead of landing as literal text, so the accepted @@ -186,16 +215,84 @@ final class SuggestionInserter { return true } - /// Commits `text` by placing it on the pasteboard and synthesizing Cmd-V, then restoring the - /// user's clipboard shortly after. Returns false (having already restored the clipboard) if any - /// synthetic event could not be created, so the caller falls back to keystroke insertion. The - /// Cmd-V is tagged synthetic the same way `insert(_:)` tags its keydown so the consuming tap - /// ignores it. - private func insertViaPaste(_ text: String) -> Bool { + /// Replaces the current terminal line without submitting it. Backspaces are the portable + /// line-editor operation at an end-of-line caret, and Cmd-V enters the terminal's bracketed-paste + /// path without pressing Return. The caller validates that the caret is at the end and that the + /// exact original buffer is still live before invoking this. + /// + /// Deletion and paste must be posted through one event source and one event-tap location. A + /// previous implementation queued HID backspaces and then synchronously pressed Edit > Paste; + /// the menu action won that race, so the queued backspaces erased the new command and continued + /// into the original English instruction. Building the whole burst first gives CoreGraphics one + /// strict order: every backspace, then Cmd-V. + func replaceTerminalLine(deletingCharacterCount: Int, with text: String) -> Bool { + guard let plan = TerminalLineReplacementPlanner.plan( + deletingCharacterCount: deletingCharacterCount, + text: text + ) else { + lastErrorMessage = "Terminal command replacement was empty or had an invalid delete count." + return false + } + + guard let preparedPasteboard = preparePasteboard(with: plan.replacementText) else { + lastErrorMessage = "Unable to prepare the translated command for terminal paste." + return false + } + + // A session event source preserves the explicit Command modifier on V. Posting every event + // at the annotated-session location also keeps this ordered burst downstream of Cotabby's + // event taps; each event is still tagged so future tap-placement changes remain fail-closed. + let source = CGEventSource(stateID: .combinedSessionState) + source?.setLocalEventsFilterDuringSuppressionState( + [.permitLocalMouseEvents, .permitSystemDefinedEvents], + state: .eventSuppressionStateSuppressionInterval + ) + + var events: [CGEvent] = [] + events.reserveCapacity(plan.operations.count * 2) + for operation in plan.operations { + let keyCode: CGKeyCode + let flags: CGEventFlags + switch operation { + case .backspace: + keyCode = Self.backspaceKeyCode + flags = [] + case .paste: + keyCode = Self.vKeyCode + flags = .maskCommand + } + + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else { + abortPreparedPasteboard(preparedPasteboard) + lastErrorMessage = "Unable to create the ordered terminal replacement events." + CotabbyLogger.suggestion.error("Terminal command replacement failed: could not create key events") + return false + } + keyDown.flags = flags + keyUp.flags = flags + suppressionController.markSynthetic(keyDown) + suppressionController.markSynthetic(keyUp) + events.append(keyDown) + events.append(keyUp) + } + + for event in events { + event.post(tap: .cgAnnotatedSessionEventTap) + } + schedulePasteboardRestore(preparedPasteboard) + lastErrorMessage = nil + CotabbyLogger.suggestion.debug( + "Replaced terminal line using \(plan.backspaceCount) ordered backspace(s), then one paste" + ) + return true + } + + /// Places text on the clipboard and returns the state needed to restore the user's prior + /// clipboard. This intentionally does not trigger Paste: callers choose either the normal menu + /// path or the terminal-only ordered event burst. + private func preparePasteboard(with text: String) -> PreparedPasteboard? { let pasteboard = NSPasteboard.general - // Snapshot the user's clipboard only when no restore is already pending. If one is (an - // overlapping paste), the pasteboard currently holds our previous completion, so re-snapshotting - // would save that and leak it back to the user; reuse the already-saved real clipboard. if pendingPasteboardRestore == nil { savedClipboardForRestore = Self.snapshotPasteboard(pasteboard) } @@ -206,9 +303,44 @@ final class SuggestionInserter { pendingPasteboardRestore?.cancel() Self.restorePasteboard(saved, to: pasteboard) clearPendingPasteboardRestore() + return nil + } + return PreparedPasteboard( + savedClipboard: saved, + expectedChangeCount: pasteboard.changeCount + ) + } + + /// Restores immediately when event construction fails before a paste can be posted. + private func abortPreparedPasteboard(_ prepared: PreparedPasteboard) { + pendingPasteboardRestore?.cancel() + Self.restorePasteboard(prepared.savedClipboard, to: NSPasteboard.general) + clearPendingPasteboardRestore() + } + + /// Gives the host time to consume the pasteboard, then restores it only if the user has not put + /// something newer on the clipboard during that window. + private func schedulePasteboardRestore(_ prepared: PreparedPasteboard) { + pendingPasteboardRestore?.cancel() + let restore = DispatchWorkItem { [weak self] in + if NSPasteboard.general.changeCount == prepared.expectedChangeCount { + Self.restorePasteboard(prepared.savedClipboard, to: NSPasteboard.general) + } + self?.clearPendingPasteboardRestore() + } + pendingPasteboardRestore = restore + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pasteboardRestoreDelay, execute: restore) + } + + /// Commits `text` by placing it on the pasteboard and synthesizing Cmd-V, then restoring the + /// user's clipboard shortly after. Returns false (having already restored the clipboard) if any + /// synthetic event could not be created, so the caller falls back to keystroke insertion. The + /// Cmd-V is tagged synthetic the same way `insert(_:)` tags its keydown so the consuming tap + /// ignores it. + private func insertViaPaste(_ text: String) -> Bool { + guard let preparedPasteboard = preparePasteboard(with: text) else { return false } - let expectedChangeCount = pasteboard.changeCount // Preferred trigger: press the host's real Edit > Paste menu item via Accessibility. No key // event exists, so neither an active IME nor the HID modifier state machine can interfere. @@ -230,9 +362,7 @@ final class SuggestionInserter { ) guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: Self.vKeyCode, keyDown: true), let keyUp = CGEvent(keyboardEventSource: source, virtualKey: Self.vKeyCode, keyDown: false) else { - pendingPasteboardRestore?.cancel() - Self.restorePasteboard(saved, to: pasteboard) - clearPendingPasteboardRestore() + abortPreparedPasteboard(preparedPasteboard) return false } keyDown.flags = .maskCommand @@ -245,19 +375,7 @@ final class SuggestionInserter { CotabbyLogger.suggestion.debug("Paste committed via synthetic Cmd-V (no Paste menu item found)") } - // Give the host time to service Cmd-V, then hand the clipboard back, but only if our completion - // is still the thing on it. If the user copied something during the window, `changeCount` - // advanced and we leave their new clipboard alone. An overlapping paste cancels this restore - // and reschedules one for the same saved clipboard. - pendingPasteboardRestore?.cancel() - let restore = DispatchWorkItem { [weak self] in - if NSPasteboard.general.changeCount == expectedChangeCount { - Self.restorePasteboard(saved, to: NSPasteboard.general) - } - self?.clearPendingPasteboardRestore() - } - pendingPasteboardRestore = restore - DispatchQueue.main.asyncAfter(deadline: .now() + Self.pasteboardRestoreDelay, execute: restore) + schedulePasteboardRestore(preparedPasteboard) return true } @@ -350,5 +468,33 @@ enum SyntheticReplacePlanner { } } +/// One keydown in a terminal whole-line replacement. The operation list is intentionally modeled +/// as data so tests can prove Paste is last; this ordering is a correctness invariant, not merely +/// an implementation detail of `SuggestionInserter`. +enum TerminalLineReplacementOperation: Equatable { + case backspace + case paste +} + +struct TerminalLineReplacementPlan: Equatable { + let backspaceCount: Int + let replacementText: String + let operations: [TerminalLineReplacementOperation] +} + +enum TerminalLineReplacementPlanner { + static func plan(deletingCharacterCount: Int, text: String) -> TerminalLineReplacementPlan? { + let normalized = text.replacingOccurrences(of: "\r", with: "") + guard deletingCharacterCount >= 0, !normalized.isEmpty else { + return nil + } + return TerminalLineReplacementPlan( + backspaceCount: deletingCharacterCount, + replacementText: normalized, + operations: Array(repeating: .backspace, count: deletingCharacterCount) + [.paste] + ) + } +} + extension SuggestionInserter: SuggestionInserting {} extension SuggestionInserter: EmojiTextInserting {} diff --git a/Cotabby/Services/Suggestion/SuggestionInteractionState.swift b/Cotabby/Services/Suggestion/SuggestionInteractionState.swift index d63d2a6c..be066d4f 100644 --- a/Cotabby/Services/Suggestion/SuggestionInteractionState.swift +++ b/Cotabby/Services/Suggestion/SuggestionInteractionState.swift @@ -71,7 +71,13 @@ final class SuggestionInteractionState { return false } - return currentContext.processIdentifier != focusedContext.processIdentifier + guard currentContext.processIdentifier == focusedContext.processIdentifier else { + return true + } + if currentContext.terminalInputRole != nil || focusedContext.terminalInputRole != nil { + return currentContext.elementIdentifier != focusedContext.elementIdentifier + } + return false } /// Reconciles the currently active session against the latest AX snapshot and stores the @@ -206,6 +212,13 @@ final class SuggestionInteractionState { guard liveContext.processIdentifier == activeSession.baseContext.processIdentifier else { return SessionValidation(session: nil, failureReason: "Key passed through because the focused field changed.") } + if liveContext.terminalInputRole != nil || activeSession.baseContext.terminalInputRole != nil, + liveContext.elementIdentifier != activeSession.baseContext.elementIdentifier { + return SessionValidation( + session: nil, + failureReason: "Key passed through because the terminal input source changed." + ) + } sessionForAcceptance = activeSession } diff --git a/Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift b/Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift index 24975a82..4a8265e4 100644 --- a/Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift +++ b/Cotabby/Services/Suggestion/SuggestionOverlayPresenter.swift @@ -33,6 +33,12 @@ struct SuggestionOverlayPresenter { return hide(reason: "Overlay hidden because the suggestion text was empty.") } + // Non-AX sources publish exact text before OCR has necessarily resolved a trustworthy + // screen anchor. A zero caret is an explicit unavailable state, never permission to guess. + guard !geometry.caretRect.isEmpty, geometry.caretRect != .zero else { + return hide(reason: "Overlay hidden until terminal caret geometry is available.") + } + // Compare against the previous visible content while ignoring `mode`, which the controller // resolves from geometry each call. If the controller swaps modes for the same text+geometry // it does the resulting state transition; we still need to invoke `showSuggestion` so the diff --git a/Cotabby/Services/Terminal/ShellPromptGeometryCoordinator.swift b/Cotabby/Services/Terminal/ShellPromptGeometryCoordinator.swift new file mode 100644 index 00000000..4757aca5 --- /dev/null +++ b/Cotabby/Services/Terminal/ShellPromptGeometryCoordinator.swift @@ -0,0 +1,132 @@ +import Foundation +import Logging + +/// Resolves shell-hook buffers to visible prompt geometry without polluting the IPC service. +/// +/// The socket service owns trusted text/session state; this coordinator owns screenshots and OCR. +/// Keeping those boundaries separate means shell autocomplete still receives exact context when +/// Screen Recording is unavailable, but the overlay stays hidden until geometry is trustworthy. +@MainActor +final class ShellPromptGeometryCoordinator { + typealias CaptureProvider = @MainActor ( + TerminalFocusSnapshot, + Int32 + ) async throws -> TerminalWindowCapture + + var onGeometryResolved: ((TerminalFocusSnapshot) -> Void)? + + /// Coalesces a fast first burst before the prompt anchor exists. Vision requests cannot be + /// cancelled once dispatched, so delaying dispatch avoids one full OCR job per keystroke. + private static let captureDebounceNanoseconds: UInt64 = 120_000_000 + + private let extractor: any ScreenTextExtracting + private let captureProvider: CaptureProvider + private let latestSnapshotProvider: @MainActor (TerminalSessionIdentity) -> TerminalFocusSnapshot? + private let isEnabled: @MainActor () -> Bool + private let logger = Logger(label: "com.cotabby.terminal-shell-geometry") + private var anchors: [TerminalSessionIdentity: TerminalPromptAnchor] = [:] + private var captureTasks: [TerminalSessionIdentity: Task] = [:] + private var previousBuffers: [TerminalSessionIdentity: String] = [:] + + init( + extractor: any ScreenTextExtracting, + captureProvider: @escaping CaptureProvider, + latestSnapshotProvider: @escaping @MainActor (TerminalSessionIdentity) -> TerminalFocusSnapshot?, + isEnabled: @escaping @MainActor () -> Bool + ) { + self.extractor = extractor + self.captureProvider = captureProvider + self.latestSnapshotProvider = latestSnapshotProvider + self.isEnabled = isEnabled + } + + /// Returns geometry immediately from a valid anchor, otherwise starts one coalesced OCR pass. + func resolve(_ snapshot: TerminalFocusSnapshot, terminalPID: Int32) -> TerminalFocusSnapshot? { + guard isEnabled() else { + invalidate(snapshot.sessionIdentity) + return nil + } + let identity = snapshot.sessionIdentity + let previous = previousBuffers[identity] + previousBuffers[identity] = snapshot.commandBuffer + if snapshot.commandBuffer.isEmpty, previous?.isEmpty == false { + // Enter advanced the terminal to a fresh row. The old prompt anchor is now stale even + // though the window itself has not moved. + anchors[identity] = nil + } + + let frame = TerminalGeometryResolver.windowFrame(forPid: terminalPID) + if let anchor = anchors[identity], + TerminalPromptAnchorResolver.isValid( + anchor, + windowFrame: frame, + cursorOffset: snapshot.cursorCharacterOffset + ), !(anchor.wasEmptyBuffer && !snapshot.commandBuffer.isEmpty) { + return applying(anchor: anchor, to: snapshot) + } + + scheduleCapture(snapshot, terminalPID: terminalPID) + return nil + } + + func invalidate(_ identity: TerminalSessionIdentity) { + captureTasks.removeValue(forKey: identity)?.cancel() + anchors[identity] = nil + previousBuffers[identity] = nil + } + + func invalidateAll() { + captureTasks.values.forEach { $0.cancel() } + captureTasks.removeAll() + anchors.removeAll() + previousBuffers.removeAll() + } + + private func scheduleCapture(_ snapshot: TerminalFocusSnapshot, terminalPID: Int32) { + let identity = snapshot.sessionIdentity + captureTasks[identity]?.cancel() + captureTasks[identity] = Task { [weak self] in + guard let self else { return } + do { + try await Task.sleep(nanoseconds: Self.captureDebounceNanoseconds) + guard !Task.isCancelled else { return } + let capture = try await captureProvider(snapshot, terminalPID) + guard !Task.isCancelled else { return } + let extraction = try await extractor.extractText(from: capture.image) + guard !Task.isCancelled, + let current = latestSnapshotProvider(identity), + current.sourceRevision >= snapshot.sourceRevision, + current.terminalBundleIdentifier == capture.descriptor.bundleIdentifier, + let anchor = TerminalPromptAnchorResolver.makeAnchor( + snapshot: current, + lines: extraction.positionedLines, + captureRegion: capture.region, + windowFrame: capture.descriptor.windowFrame + ) else { return } + anchors[identity] = anchor + onGeometryResolved?(applying(anchor: anchor, to: current)) + } catch is CancellationError { + return + } catch { + logger.debug("Shell prompt geometry unavailable: \(error.localizedDescription)") + } + captureTasks[identity] = nil + } + } + + private func applying( + anchor: TerminalPromptAnchor, + to snapshot: TerminalFocusSnapshot + ) -> TerminalFocusSnapshot { + let geometry = TerminalPromptAnchorResolver.geometry( + cursorOffset: snapshot.cursorCharacterOffset, + anchor: anchor + ) + return snapshot.withGeometry( + windowFrame: AXHelper.cocoaRect(fromAccessibilityRect: anchor.windowFrame), + cursorRect: AXHelper.cocoaRect(fromAccessibilityRect: geometry.caret), + promptLineRect: AXHelper.cocoaRect(fromAccessibilityRect: geometry.input), + observedCellWidth: anchor.cellWidth + ) + } +} diff --git a/Cotabby/Services/Terminal/TerminalGeometryResolver.swift b/Cotabby/Services/Terminal/TerminalGeometryResolver.swift new file mode 100644 index 00000000..0a5de6aa --- /dev/null +++ b/Cotabby/Services/Terminal/TerminalGeometryResolver.swift @@ -0,0 +1,60 @@ +import AppKit +import ApplicationServices +import CoreGraphics +import Foundation + +/// Small AX boundary for terminal-window metadata. +/// +/// Terminal text is opaque, but the owning app still exposes its active window frame and title. +/// OCR coordinators use the frame to map Vision boxes into screen coordinates and the TUI detector +/// may use the title as its cheapest Claude Code signal. +@MainActor +enum TerminalGeometryResolver { + struct CellMetrics: Equatable, Sendable { + let cellWidth: CGFloat + let cellHeight: CGFloat + } + + static let defaultCellMetrics = CellMetrics(cellWidth: 7.8, cellHeight: 17.0) + + static func terminalAppPid(forBundleIdentifier bundleIdentifier: String) -> Int32? { + NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + .first(where: { $0.isActive })?.processIdentifier + ?? NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + .first?.processIdentifier + } + + /// Returns the active terminal frame in Accessibility/ScreenCaptureKit's top-left coordinate + /// space. Conversion to AppKit happens only after prompt/caret geometry has been resolved. + static func windowFrame(forPid pid: pid_t) -> CGRect? { + guard pid > 0 else { return nil } + let app = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(app, 0.05) + if let window = window(from: kAXFocusedWindowAttribute as CFString, app: app), + let frame = AXHelper.rectValue(for: "AXFrame" as CFString, on: window) { + return frame + } + if let window = window(from: kAXMainWindowAttribute as CFString, app: app), + let frame = AXHelper.rectValue(for: "AXFrame" as CFString, on: window) { + return frame + } + return nil + } + + static func windowTitle(forPid pid: pid_t) -> String? { + guard pid > 0 else { return nil } + let app = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(app, 0.05) + guard let window = window(from: kAXFocusedWindowAttribute as CFString, app: app) else { + return nil + } + return AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: window) + } + + private static func window(from attribute: CFString, app: AXUIElement) -> AXUIElement? { + var raw: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, attribute, &raw) == .success, + let raw else { return nil } + return unsafeBitCast(raw, to: AXUIElement.self) + } +} diff --git a/Cotabby/Services/Terminal/TerminalIntegrationResourceInstaller.swift b/Cotabby/Services/Terminal/TerminalIntegrationResourceInstaller.swift new file mode 100644 index 00000000..7a3f1db2 --- /dev/null +++ b/Cotabby/Services/Terminal/TerminalIntegrationResourceInstaller.swift @@ -0,0 +1,75 @@ +import Darwin +import Foundation + +/// Copies code-signed shell hook resources to a stable per-product user directory. +/// +/// The app bundle is sealed and may move between updates or development builds, so shell startup +/// files should never source a mutable file inside `Contents/Resources`. Installation is an atomic +/// byte-for-byte copy; this type does not edit the user's shell configuration. +nonisolated struct TerminalIntegrationResourceInstaller { + enum InstallError: LocalizedError { + case bundledHooksMissing(URL) + case bundledHookMissing(String) + + var errorDescription: String? { + switch self { + case let .bundledHooksMissing(url): + "Bundled shell integration directory is missing at \(url.path)." + case let .bundledHookMissing(name): + "Bundled shell integration hook \(name) is missing." + } + } + } + + let paths: TerminalIntegrationPaths + let bundledHooksDirectory: URL + let fileManager: FileManager + + init( + paths: TerminalIntegrationPaths, + bundledHooksDirectory: URL? = Bundle.main.resourceURL? + .appendingPathComponent("shell-integration", isDirectory: true), + fileManager: FileManager = .default + ) { + self.paths = paths + self.bundledHooksDirectory = bundledHooksDirectory + ?? URL(fileURLWithPath: "/nonexistent/cotabby-shell-integration") + self.fileManager = fileManager + } + + func install() throws { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: bundledHooksDirectory.path, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw InstallError.bundledHooksMissing(bundledHooksDirectory) + } + + try fileManager.createDirectory( + at: paths.hooksDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard chmod(paths.rootDirectory.path, 0o700) == 0, + chmod(paths.hooksDirectory.path, 0o700) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EACCES) + } + + for shell in ShellType.allCasesForInstallation { + let name = "cotabby.\(shell.rawValue)" + let source = bundledHooksDirectory.appendingPathComponent(name) + guard fileManager.fileExists(atPath: source.path) else { + throw InstallError.bundledHookMissing(name) + } + let data = try Data(contentsOf: source) + let destination = paths.hookURL(for: shell) + try data.write(to: destination, options: .atomic) + guard chmod(destination.path, 0o600) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EACCES) + } + } + } +} + +private nonisolated extension ShellType { + static let allCasesForInstallation: [ShellType] = [.zsh, .bash, .fish] +} diff --git a/Cotabby/Services/Terminal/TerminalIntegrationService.swift b/Cotabby/Services/Terminal/TerminalIntegrationService.swift new file mode 100644 index 00000000..d57615d0 --- /dev/null +++ b/Cotabby/Services/Terminal/TerminalIntegrationService.swift @@ -0,0 +1,516 @@ +import Darwin +import Foundation +import Logging + +/// Main-actor session model for cooperative terminal shell integration. +/// +/// The nested socket server owns POSIX descriptors and JSON framing on a private serial queue. Only +/// validated value messages cross back to the main actor, keeping per-keystroke IPC away from UI +/// work while making session callbacks safe for focus/coordinator consumers. +@MainActor +final class TerminalIntegrationService { + var onSnapshotUpdate: ((TerminalFocusSnapshot) -> Void)? + var onSessionChange: (() -> Void)? + + private(set) var sessions: [TerminalSessionIdentity: TerminalSession] = [:] + private(set) var isRunning = false + + let paths: TerminalIntegrationPaths + private let installer: TerminalIntegrationResourceInstaller + private let logger = Logger(label: "com.cotabby.terminal-integration") + private var server: TerminalSocketServer? + private var pruneTimer: Timer? + + init( + paths: TerminalIntegrationPaths = .current(), + bundledHooksDirectory: URL? = Bundle.main.resourceURL? + .appendingPathComponent("shell-integration", isDirectory: true) + ) { + self.paths = paths + installer = TerminalIntegrationResourceInstaller( + paths: paths, + bundledHooksDirectory: bundledHooksDirectory + ) + } + + @discardableResult + func start() -> Bool { + guard !isRunning else { return true } + do { + try installer.install() + let server = TerminalSocketServer(socketURL: paths.socketURL) { [weak self] message in + Task { @MainActor [weak self] in + self?.handle(message) + } + } + try server.start() + self.server = server + isRunning = true + startPruneTimer() + logger.info("Terminal integration listening at \(self.paths.socketURL.path)") + return true + } catch { + logger.error("Terminal integration failed to start: \(error.localizedDescription)") + server = nil + isRunning = false + return false + } + } + + func stop() { + pruneTimer?.invalidate() + pruneTimer = nil + server?.stop() + server = nil + isRunning = false + let hadSessions = !sessions.isEmpty + sessions.removeAll() + if hadSessions { + onSessionChange?() + } + } + + func hasActiveSession(forBundleIdentifier bundleIdentifier: String) -> Bool { + sessions.values.contains { $0.terminalBundleIdentifier == bundleIdentifier } + } + + /// Treat a hook as authoritative only while it is still publishing. The preference toggle by + /// itself must never make an opaque AX terminal surface eligible after a hook goes stale. + func isRecentlyReporting( + forBundleIdentifier bundleIdentifier: String, + within interval: TimeInterval = 2.5, + now: Date = Date() + ) -> Bool { + sessions.values.contains { + $0.terminalBundleIdentifier == bundleIdentifier + && now.timeIntervalSince($0.lastMessageAt) <= interval + } + } + + func activeShellPIDs(forBundleIdentifier bundleIdentifier: String) -> [Int32] { + sessions.values.compactMap { + $0.terminalBundleIdentifier == bundleIdentifier ? $0.identity.shellPid : nil + } + } + + func identity(forElementIdentifier elementIdentifier: String) -> TerminalSessionIdentity? { + sessions.keys.first { $0.elementIdentifier == elementIdentifier } + } + + func latestSnapshot(forBundleIdentifier bundleIdentifier: String) -> TerminalFocusSnapshot? { + sessions.values + .filter { $0.terminalBundleIdentifier == bundleIdentifier } + .compactMap(\.latestSnapshot) + .max { $0.timestamp < $1.timestamp } + } + + func latestSnapshot(for identity: TerminalSessionIdentity) -> TerminalFocusSnapshot? { + sessions[identity]?.latestSnapshot + } + + func applyOptimisticInsertion(identity: TerminalSessionIdentity, insertedText: String) { + guard !insertedText.isEmpty, + var session = sessions[identity], + let snapshot = session.latestSnapshot else { return } + let updated = snapshot.appendingInsertedText(insertedText) + session.latestSnapshot = updated + session.lastMessageAt = Date() + sessions[identity] = session + onSnapshotUpdate?(updated) + } + + func applyOptimisticReplacement(identity: TerminalSessionIdentity, replacementText: String) { + guard !replacementText.isEmpty, + var session = sessions[identity], + let snapshot = session.latestSnapshot else { return } + let updated = snapshot.replacingCommandBuffer(with: replacementText) + session.latestSnapshot = updated + session.lastMessageAt = Date() + sessions[identity] = session + onSnapshotUpdate?(updated) + } + + func pruneDeadSessions() { + let dead = sessions.keys.filter { identity in + kill(identity.shellPid, 0) != 0 && errno == ESRCH + } + guard !dead.isEmpty else { return } + for identity in dead { + sessions[identity] = nil + } + onSessionChange?() + } + + private func startPruneTimer() { + let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in self?.pruneDeadSessions() } + } + RunLoop.main.add(timer, forMode: .common) + pruneTimer = timer + } + + private func handle(_ message: TerminalIpcMessage) { + switch message.type { + case .buffer: + handleBuffer(message) + case .disconnect: + handleDisconnect(message) + } + } + + private func handleBuffer(_ message: TerminalIpcMessage) { + guard let text = message.text, + text.count <= 32_768, + !text.contains("\0"), + let rawCursor = message.cursor, + rawCursor >= 0, + let shell = message.shell, + let terminal = message.terminal, + terminal.count <= 255, + TerminalAppDetector.isTerminalHost(bundleIdentifier: terminal), + let pid = message.pid, + Self.isCurrentUserProcess(pid), + let nonce = message.session, + Self.isValidNonce(nonce), + let tty = message.tty, + tty.hasPrefix("/dev/"), + tty.count <= 255, + !tty.contains("\0"), + let revision = message.revision, + (message.cwd?.count ?? 0) <= 4_096, + message.cwd?.contains("\0") != true else { + logger.warning("Rejected malformed or untrusted terminal buffer frame") + return + } + + let maximumCursor = shell == .bash ? text.utf8.count : text.count + guard rawCursor <= maximumCursor else { + logger.warning("Rejected terminal buffer with an out-of-range cursor") + return + } + + let identity = TerminalSessionIdentity(shellPid: pid, nonce: nonce) + if let existing = sessions[identity] { + guard existing.terminalBundleIdentifier == terminal, + existing.tty == tty || existing.tty == nil, + revision > existing.lastWireRevision else { + return + } + } + + let snapshot = TerminalFocusSnapshot( + sessionIdentity: identity, + commandBuffer: text, + cursorCharacterOffset: TerminalFocusSnapshot.normalizedCharacterOffset( + rawOffset: rawCursor, + text: text, + shell: shell + ), + shellType: shell, + terminalBundleIdentifier: terminal, + tty: tty, + workingDirectory: message.cwd, + sourceRevision: revision + ) + let now = Date() + let isNew = sessions[identity] == nil + if var session = sessions[identity] { + session.shellType = shell + session.tty = tty + session.lastMessageAt = now + session.lastWireRevision = revision + session.latestSnapshot = snapshot + sessions[identity] = session + } else { + sessions[identity] = TerminalSession( + identity: identity, + shellType: shell, + terminalBundleIdentifier: terminal, + tty: tty, + connectedAt: now, + lastMessageAt: now, + lastWireRevision: revision, + latestSnapshot: snapshot + ) + } + if isNew { + onSessionChange?() + } + onSnapshotUpdate?(snapshot) + } + + private func handleDisconnect(_ message: TerminalIpcMessage) { + guard let pid = message.pid, + let nonce = message.session, + Self.isValidNonce(nonce) else { return } + let identity = TerminalSessionIdentity(shellPid: pid, nonce: nonce) + guard sessions.removeValue(forKey: identity) != nil else { return } + onSessionChange?() + } + + private static func isCurrentUserProcess(_ pid: Int32) -> Bool { + guard pid > 1 else { return false } + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + let read = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) + return read == size && info.pbi_uid == geteuid() + } + + private static func isValidNonce(_ nonce: String) -> Bool { + guard !nonce.isEmpty, nonce.count <= 64 else { return false } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + return nonce.unicodeScalars.allSatisfy(allowed.contains) + } +} + +/// Bounded, same-user Unix-domain socket server. +/// +/// Descriptor state is isolated to `queue`. Every descriptor has exactly one close owner in the +/// explicit teardown helpers; DispatchSource cancel handlers never close descriptors, avoiding the +/// reused-FD double-close race in the prototype implementation. +nonisolated final class TerminalSocketServer: @unchecked Sendable { + enum ServerError: LocalizedError { + case pathTooLong + case unsafeExistingEndpoint(String) + case endpointInUse + case posix(operation: String, code: Int32) + + var errorDescription: String? { + switch self { + case .pathTooLong: "Terminal socket path is too long." + case let .unsafeExistingEndpoint(reason): "Refusing terminal socket path: \(reason)." + case .endpointInUse: "Another Cotabby process already owns the terminal socket." + case let .posix(operation, code): "\(operation) failed: \(String(cString: strerror(code)))." + } + } + } + + private struct Client { + let source: DispatchSourceRead + var buffer: Data + } + + private static let maximumFrameBytes = 64 * 1_024 + private static let maximumClients = 32 + + private let socketURL: URL + private let onMessage: @Sendable (TerminalIpcMessage) -> Void + private let queue = DispatchQueue(label: "com.cotabby.terminal-ipc", qos: .userInitiated) + private var listenerDescriptor: Int32 = -1 + private var listenerSource: DispatchSourceRead? + private var clients: [Int32: Client] = [:] + private var ownsSocketPath = false + + init(socketURL: URL, onMessage: @escaping @Sendable (TerminalIpcMessage) -> Void) { + self.socketURL = socketURL + self.onMessage = onMessage + } + + func start() throws { + try queue.sync { try startOnQueue() } + } + + func stop() { + queue.sync { stopOnQueue() } + } + + private func startOnQueue() throws { + guard listenerSource == nil else { return } + let directory = socketURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard chmod(directory.path, 0o700) == 0 else { + throw ServerError.posix(operation: "chmod socket directory", code: errno) + } + try prepareEndpointPath() + + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw ServerError.posix(operation: "socket", code: errno) + } + do { + try setNonBlocking(descriptor) + var address = try socketAddress(path: socketURL.path) + let length = socklen_t(MemoryLayout.size + socketURL.path.utf8.count + 1) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, length) + } + } + guard result == 0 else { + throw ServerError.posix(operation: "bind", code: errno) + } + ownsSocketPath = true + guard chmod(socketURL.path, 0o600) == 0 else { + throw ServerError.posix(operation: "chmod socket", code: errno) + } + guard Darwin.listen(descriptor, 16) == 0 else { + throw ServerError.posix(operation: "listen", code: errno) + } + + listenerDescriptor = descriptor + let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue) + source.setEventHandler { [weak self] in self?.acceptAvailableClients() } + source.resume() + listenerSource = source + } catch { + Darwin.close(descriptor) + if ownsSocketPath { + Darwin.unlink(socketURL.path) + ownsSocketPath = false + } + throw error + } + } + + private func stopOnQueue() { + for descriptor in Array(clients.keys) { + removeClient(descriptor) + } + if let source = listenerSource { + source.cancel() + listenerSource = nil + } + if listenerDescriptor >= 0 { + Darwin.close(listenerDescriptor) + listenerDescriptor = -1 + } + if ownsSocketPath { + Darwin.unlink(socketURL.path) + ownsSocketPath = false + } + } + + private func prepareEndpointPath() throws { + var info = stat() + guard lstat(socketURL.path, &info) == 0 else { + if errno == ENOENT { return } + throw ServerError.posix(operation: "lstat", code: errno) + } + guard (info.st_mode & S_IFMT) == S_IFSOCK else { + throw ServerError.unsafeExistingEndpoint("existing path is not a socket") + } + guard info.st_uid == geteuid() else { + throw ServerError.unsafeExistingEndpoint("existing socket is owned by another user") + } + + let probe = socket(AF_UNIX, SOCK_STREAM, 0) + guard probe >= 0 else { throw ServerError.posix(operation: "socket probe", code: errno) } + defer { Darwin.close(probe) } + var address = try socketAddress(path: socketURL.path) + let length = socklen_t(MemoryLayout.size + socketURL.path.utf8.count + 1) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(probe, $0, length) + } + } + if result == 0 { + throw ServerError.endpointInUse + } + let connectError = errno + guard connectError == ECONNREFUSED || connectError == ENOENT else { + throw ServerError.posix(operation: "probe existing socket", code: connectError) + } + guard Darwin.unlink(socketURL.path) == 0 else { + throw ServerError.posix(operation: "unlink stale socket", code: errno) + } + } + + private func acceptAvailableClients() { + while clients.count < Self.maximumClients { + let descriptor = Darwin.accept(listenerDescriptor, nil, nil) + guard descriptor >= 0 else { + if errno == EAGAIN || errno == EWOULDBLOCK { return } + return + } + var peerUser: uid_t = 0 + var peerGroup: gid_t = 0 + guard getpeereid(descriptor, &peerUser, &peerGroup) == 0, + peerUser == geteuid() else { + Darwin.close(descriptor) + continue + } + do { + try setNonBlocking(descriptor) + } catch { + Darwin.close(descriptor) + continue + } + let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: queue) + source.setEventHandler { [weak self] in self?.readClient(descriptor) } + clients[descriptor] = Client(source: source, buffer: Data()) + source.resume() + } + } + + private func readClient(_ descriptor: Int32) { + guard clients[descriptor] != nil else { return } + var bytes = [UInt8](repeating: 0, count: 4_096) + while true { + let count = Darwin.read(descriptor, &bytes, bytes.count) + if count > 0 { + clients[descriptor]?.buffer.append(contentsOf: bytes[0.. Bool { + while let buffer = clients[descriptor]?.buffer, + let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + let line = Data(buffer[..= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw ServerError.posix(operation: "fcntl", code: errno) + } + } + + private func socketAddress(path: String) throws -> sockaddr_un { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = path.utf8CString + guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw ServerError.pathTooLong + } + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: bytes.count) { destination in + bytes.withUnsafeBufferPointer { source in + _ = memcpy(destination, source.baseAddress!, source.count) + } + } + } + return address + } +} diff --git a/Cotabby/Services/Terminal/TerminalWindowScreenshotService.swift b/Cotabby/Services/Terminal/TerminalWindowScreenshotService.swift new file mode 100644 index 00000000..91afafb0 --- /dev/null +++ b/Cotabby/Services/Terminal/TerminalWindowScreenshotService.swift @@ -0,0 +1,137 @@ +import AppKit +import CoreGraphics +import Foundation +import ScreenCaptureKit + +/// Captures the exact frontmost terminal window used by terminal OCR sources. +/// +/// The returned ScreenCaptureKit window identifier is part of the source identity. That prevents +/// a slow OCR result from window A being injected after the user has moved to window B in the same +/// terminal process—a case a bundle-id-only stale check cannot distinguish. +struct TerminalWindowCapture: @unchecked Sendable { + struct Descriptor: Equatable, Sendable { + let windowID: CGWindowID + let windowFrame: CGRect + let pid: Int32 + let bundleIdentifier: String + let applicationName: String + let title: String? + } + + let descriptor: Descriptor + /// Global Core Graphics coordinates for the pixels in `image`. + let region: CGRect + let image: CGImage +} + +enum TerminalWindowCaptureError: LocalizedError { + case screenRecordingPermissionMissing + case noVisibleWindow(Int32) + case captureFailed(String) + + var errorDescription: String? { + switch self { + case .screenRecordingPermissionMissing: + return "Screen Recording permission is required for terminal OCR." + case let .noVisibleWindow(pid): + return "No visible terminal window was found for process \(pid)." + case let .captureFailed(message): + return "Terminal screenshot failed: \(message)" + } + } +} + +struct TerminalWindowScreenshotService { + /// `preferredRegion` is a global CG rect, normally an embedded terminal pane. Dedicated + /// terminals pass nil and capture their whole window so OCR can verify Claude Code chrome. + func capture( + pid: Int32, + bundleIdentifier: String, + applicationName: String, + preferredRegion: CGRect? = nil + ) async throws -> TerminalWindowCapture { + guard CGPreflightScreenCaptureAccess() else { + throw TerminalWindowCaptureError.screenRecordingPermissionMissing + } + + let content = try await shareableContent() + let candidates = content.windows.filter { + $0.owningApplication?.processID == pid_t(pid) && $0.isOnScreen + } + guard let window = candidates.first(where: \.isActive) ?? candidates.first else { + throw TerminalWindowCaptureError.noVisibleWindow(pid) + } + + let region = (preferredRegion?.intersection(window.frame)).flatMap { + $0.isEmpty ? nil : $0 + } ?? window.frame + let localRegion = CGRect( + x: region.minX - window.frame.minX, + y: region.minY - window.frame.minY, + width: region.width, + height: region.height + ) + let scale = backingScaleFactor(forCoreGraphicsRect: region) + let configuration = SCStreamConfiguration() + configuration.sourceRect = localRegion + configuration.width = max(Int((localRegion.width * scale).rounded(.up)), 1) + configuration.height = max(Int((localRegion.height * scale).rounded(.up)), 1) + configuration.showsCursor = false + + let image = try await captureImage( + filter: SCContentFilter(desktopIndependentWindow: window), + configuration: configuration + ) + return TerminalWindowCapture( + descriptor: .init( + windowID: window.windowID, + windowFrame: window.frame, + pid: pid, + bundleIdentifier: bundleIdentifier, + applicationName: applicationName, + title: window.title + ), + region: region, + image: image + ) + } + + private func shareableContent() async throws -> SCShareableContent { + try await withCheckedThrowingContinuation { continuation in + SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: true) { content, error in + if let error { + continuation.resume(throwing: TerminalWindowCaptureError.captureFailed(error.localizedDescription)) + } else if let content { + continuation.resume(returning: content) + } else { + continuation.resume(throwing: TerminalWindowCaptureError.captureFailed("No shareable content.")) + } + } + } + } + + private func captureImage( + filter: SCContentFilter, + configuration: SCStreamConfiguration + ) async throws -> CGImage { + try await withCheckedThrowingContinuation { continuation in + SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) { image, error in + if let error { + continuation.resume(throwing: TerminalWindowCaptureError.captureFailed(error.localizedDescription)) + } else if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: TerminalWindowCaptureError.captureFailed("No image returned.")) + } + } + } + } + + private func backingScaleFactor(forCoreGraphicsRect rect: CGRect) -> CGFloat { + let appKitRect = AXHelper.cocoaRect(fromAccessibilityRect: rect) + let point = CGPoint(x: appKitRect.midX, y: appKitRect.midY) + return NSScreen.screens.first(where: { $0.frame.contains(point) })?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor + ?? 2 + } +} diff --git a/Cotabby/Services/Terminal/TuiContextCoordinator.swift b/Cotabby/Services/Terminal/TuiContextCoordinator.swift new file mode 100644 index 00000000..83ce46fd --- /dev/null +++ b/Cotabby/Services/Terminal/TuiContextCoordinator.swift @@ -0,0 +1,232 @@ +import AppKit +import CoreGraphics +import Foundation +import Logging + +/// Owns the debounced, permissioned Claude Code terminal-OCR lifecycle. +/// +/// Shell hooks and TUI OCR deliberately remain separate sources: hooks publish exact line-editor +/// state, while a full-screen TUI requires pixels and Screen Recording permission. This coordinator +/// cancels and clears only snapshots it owns, so a late OCR failure cannot tear down a newer shell +/// snapshot from the same terminal. +@MainActor +final class TuiContextCoordinator { + struct Candidate: Equatable, Sendable { + let bundleIdentifier: String + let applicationName: String + let pid: Int32 + let title: String? + /// Optional integrated-terminal pane in global Core Graphics coordinates. + let preferredCaptureRegion: CGRect? + } + + typealias CandidateProvider = @MainActor () -> Candidate? + typealias CaptureProvider = @MainActor (Candidate) async throws -> TerminalWindowCapture + + private static let debounceNanoseconds: UInt64 = 180_000_000 + private static let heartbeatNanoseconds: UInt64 = 1_000_000_000 + + private let reader: TuiContextReader + private let candidateProvider: CandidateProvider + private let captureProvider: CaptureProvider + private let foregroundProcessProvider: @MainActor (Candidate) -> [String] + private let isEnabled: @MainActor () -> Bool + private let isShellActivelyReporting: @MainActor (String) -> Bool + private let injectSnapshot: @MainActor (FocusedInputSnapshot) -> Void + private let clearInjection: @MainActor () -> Void + private let logger = Logger(label: "com.cotabby.terminal-tui") + + private var debounceTask: Task? + private var captureTask: Task? + private var heartbeatTask: Task? + private var hasInjectedSnapshot = false + private var sourceRevision: UInt64 = 0 + private var lastPublishedSignature: String? + + init( + reader: TuiContextReader, + candidateProvider: @escaping CandidateProvider, + captureProvider: @escaping CaptureProvider, + foregroundProcessProvider: @escaping @MainActor (Candidate) -> [String], + isEnabled: @escaping @MainActor () -> Bool, + isShellActivelyReporting: @escaping @MainActor (String) -> Bool, + injectSnapshot: @escaping @MainActor (FocusedInputSnapshot) -> Void, + clearInjection: @escaping @MainActor () -> Void + ) { + self.reader = reader + self.candidateProvider = candidateProvider + self.captureProvider = captureProvider + self.foregroundProcessProvider = foregroundProcessProvider + self.isEnabled = isEnabled + self.isShellActivelyReporting = isShellActivelyReporting + self.injectSnapshot = injectSnapshot + self.clearInjection = clearInjection + } + + func start() { + guard heartbeatTask == nil else { return } + heartbeatTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.heartbeatNanoseconds) + guard !Task.isCancelled else { return } + self?.evaluateAndSchedule() + } + } + evaluateAndSchedule() + } + + func stop() { + heartbeatTask?.cancel() + heartbeatTask = nil + cancelPending(clearOwnedSnapshot: true) + } + + func observeInput() { + evaluateAndSchedule() + } + + func settingsOrPermissionChanged() { + guard isEnabled() else { + cancelPending(clearOwnedSnapshot: true) + return + } + evaluateAndSchedule() + } + + private func evaluateAndSchedule() { + guard isEnabled(), let candidate = candidateProvider() else { + cancelPending(clearOwnedSnapshot: true) + return + } + guard !isShellActivelyReporting(candidate.bundleIdentifier) else { + cancelPending(clearOwnedSnapshot: true) + return + } + + let classification = TuiSessionDetector.classification( + bundleIdentifier: candidate.bundleIdentifier, + terminalAccessibilityTitle: candidate.title, + foregroundProcessNames: { foregroundProcessProvider(candidate) } + ) + guard classification != .notClaudeCode else { + cancelPending(clearOwnedSnapshot: true) + return + } + + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: Self.debounceNanoseconds) + guard !Task.isCancelled else { return } + await self?.capture(candidate: candidate) + } + } + + private func capture(candidate: Candidate) async { + captureTask?.cancel() + captureTask = Task { [weak self] in + guard let self else { return } + do { + let capture = try await captureProvider(candidate) + guard !Task.isCancelled else { return } + let reading = try await reader.read(image: capture.image) + guard !Task.isCancelled else { return } + + // OCR is asynchronous. Re-check the frontmost app/window frame before injecting so + // pixels captured before an app switch can never become current focus afterward. + guard let current = candidateProvider(), + current.bundleIdentifier == candidate.bundleIdentifier, + current.pid == candidate.pid, + !isShellActivelyReporting(current.bundleIdentifier), + windowStillMatches(capture.descriptor.windowFrame, currentPID: current.pid) + else { return } + + guard reading.looksLikeClaudeCode, + reading.promptLineBox != nil else { + clearOwnedSnapshot() + return + } + + let geometry = Self.geometry(for: reading, capture: capture) + let signature = [ + String(capture.descriptor.windowID), + reading.promptText, + NSStringFromRect(geometry.caret) + ].joined(separator: "|") + guard signature != lastPublishedSignature else { return } + lastPublishedSignature = signature + sourceRevision &+= 1 + let snapshot = TuiFocusAdapter.adapt( + reading: reading, + capture: capture, + caretRect: geometry.caret, + inputFrameRect: geometry.input, + sourceRevision: sourceRevision + ) + hasInjectedSnapshot = true + injectSnapshot(snapshot) + } catch is CancellationError { + return + } catch { + logger.debug("Claude Code OCR unavailable: \(error.localizedDescription)") + clearOwnedSnapshot() + } + } + await captureTask?.value + } + + private func cancelPending(clearOwnedSnapshot: Bool) { + debounceTask?.cancel() + debounceTask = nil + captureTask?.cancel() + captureTask = nil + if clearOwnedSnapshot { self.clearOwnedSnapshot() } + } + + private func clearOwnedSnapshot() { + guard hasInjectedSnapshot else { return } + hasInjectedSnapshot = false + lastPublishedSignature = nil + clearInjection() + } + + private func windowStillMatches(_ capturedFrame: CGRect, currentPID: Int32) -> Bool { + guard let currentFrame = TerminalGeometryResolver.windowFrame(forPid: currentPID) else { + return false + } + return abs(currentFrame.minX - capturedFrame.minX) <= 1 + && abs(currentFrame.minY - capturedFrame.minY) <= 1 + && abs(currentFrame.width - capturedFrame.width) <= 1 + && abs(currentFrame.height - capturedFrame.height) <= 1 + } + + private static func geometry( + for reading: TuiContextReader.PromptReading, + capture: TerminalWindowCapture + ) -> (caret: CGRect, input: CGRect) { + let metrics = TerminalGeometryResolver.defaultCellMetrics + let box = reading.promptLineBox ?? .zero + let region = capture.region + let lineCG = CGRect( + x: region.minX + box.minX * region.width, + y: region.minY + (1 - box.maxY) * region.height, + width: max(box.width * region.width, metrics.cellWidth), + height: max(box.height * region.height, metrics.cellHeight) + ) + let caretCG = CGRect( + x: min(lineCG.maxX + 2, region.maxX - metrics.cellWidth), + y: lineCG.minY, + width: metrics.cellWidth, + height: lineCG.height + ) + let inputCG = CGRect( + x: region.minX, + y: lineCG.minY, + width: region.width, + height: lineCG.height + ) + return ( + AXHelper.cocoaRect(fromAccessibilityRect: caretCG), + AXHelper.cocoaRect(fromAccessibilityRect: inputCG) + ) + } +} diff --git a/Cotabby/Services/Terminal/TuiContextReader.swift b/Cotabby/Services/Terminal/TuiContextReader.swift new file mode 100644 index 00000000..ed1138ee --- /dev/null +++ b/Cotabby/Services/Terminal/TuiContextReader.swift @@ -0,0 +1,103 @@ +import CoreGraphics +import Foundation + +/// Reduces one terminal-window OCR result to Claude Code's editable prompt. +/// +/// This type is intentionally independent of ScreenCaptureKit and app focus. It owns only the +/// pixel-to-text interpretation, which makes the risky heuristic testable with fixed OCR fixtures. +struct TuiContextReader { + struct PromptReading: Equatable, Sendable { + let promptText: String + let estimatedCursorOffset: Int + let promptLineBox: CGRect? + let recognizedLineCount: Int + let latencyMilliseconds: Int + let looksLikeClaudeCode: Bool + } + + enum ReadError: LocalizedError { + case failed(String) + + var errorDescription: String? { + switch self { + case let .failed(message): "Claude Code OCR failed: \(message)" + } + } + } + + private static let promptGlyphs = ["❯", "›", ">", ")"] + /// These are stable screen chrome, not user content. Requiring one makes process-wide + /// detection safe when Claude runs in another tab of the same terminal application. + private static let screenMarkers = [ + "Claude Code", + "context left", + "esc to interrupt", + "bypass permissions", + "shift+tab" + ] + + private let extractor: any ScreenTextExtracting + + init(extractor: any ScreenTextExtracting = ScreenTextExtractor()) { + self.extractor = extractor + } + + func read(image: CGImage) async throws -> PromptReading { + let startedAt = Date() + let extracted: ExtractedScreenText + do { + extracted = try await extractor.extractText(from: image) + } catch ScreenTextExtractionError.noRecognizedText { + return PromptReading( + promptText: "", + estimatedCursorOffset: 0, + promptLineBox: nil, + recognizedLineCount: 0, + latencyMilliseconds: elapsed(since: startedAt), + looksLikeClaudeCode: false + ) + } catch { + throw ReadError.failed(error.localizedDescription) + } + + let prompt = promptLine(in: extracted) + return PromptReading( + promptText: prompt.text, + estimatedCursorOffset: prompt.text.count, + promptLineBox: prompt.box, + recognizedLineCount: extracted.lineCount, + latencyMilliseconds: elapsed(since: startedAt), + looksLikeClaudeCode: Self.screenMarkers.contains { + extracted.text.localizedCaseInsensitiveContains($0) + } + ) + } + + private func promptLine(in extraction: ExtractedScreenText) -> (text: String, box: CGRect?) { + let glyphLines = extraction.positionedLines.compactMap { line -> RecognizedTextLine? in + Self.promptGlyphs.contains(where: { line.text.hasPrefix($0) }) ? line : nil + } + if let line = glyphLines.min(by: { $0.boundingBox.minY < $1.boundingBox.minY }) { + let glyph = Self.promptGlyphs.first(where: { line.text.hasPrefix($0) }) ?? "" + return ( + String(line.text.dropFirst(glyph.count)).trimmingCharacters(in: .whitespaces), + line.boundingBox + ) + } + + // Geometry-free test fakes retain a safe fallback. Production OCR always provides boxes. + let textLines = extraction.text.split(separator: "\n").map { + String($0).trimmingCharacters(in: .whitespaces) + } + for line in textLines.reversed() { + if let glyph = Self.promptGlyphs.first(where: { line.hasPrefix($0) }) { + return (String(line.dropFirst(glyph.count)).trimmingCharacters(in: .whitespaces), nil) + } + } + return ("", nil) + } + + private func elapsed(since start: Date) -> Int { + Int(Date().timeIntervalSince(start) * 1_000) + } +} diff --git a/Cotabby/Services/Visual/ScreenTextExtractor.swift b/Cotabby/Services/Visual/ScreenTextExtractor.swift index 2111627b..255916e8 100644 --- a/Cotabby/Services/Visual/ScreenTextExtractor.swift +++ b/Cotabby/Services/Visual/ScreenTextExtractor.swift @@ -11,6 +11,15 @@ import Logging /// it is bounded semantic extraction for autocomplete context. This pass favors useful text /// recovery over minimum latency because the output is captured once per focused field. +/// One OCR line with the normalized Vision box needed by terminal prompt anchoring. +/// Confidence remains in `OCRTextHygiene.OCRLine`; the extractor publishes both parallel views so +/// adding geometry does not regress the existing cleanup pipeline. +nonisolated struct RecognizedTextLine: Equatable, Sendable { + let text: String + let confidence: Float + let boundingBox: CGRect +} + struct ExtractedScreenText: Sendable { let text: String let lineCount: Int @@ -19,11 +28,19 @@ struct ExtractedScreenText: Sendable { /// real values instead of a synthesized constant. Defaults to empty for callers (and tests) that /// only supply joined text. let lines: [OCRTextHygiene.OCRLine] + /// Same recognized lines with Vision-normalized bottom-left-origin geometry. + let positionedLines: [RecognizedTextLine] - init(text: String, lineCount: Int, lines: [OCRTextHygiene.OCRLine] = []) { + init( + text: String, + lineCount: Int, + lines: [OCRTextHygiene.OCRLine] = [], + positionedLines: [RecognizedTextLine] = [] + ) { self.text = text self.lineCount = lineCount self.lines = lines + self.positionedLines = positionedLines } } @@ -127,7 +144,7 @@ struct ScreenTextExtractor: ScreenTextExtracting { // Keep each line's confidence (from its top candidate) so the hygiene pass can drop // the recognizer's weakest guesses; the joined `text` below is for logging and the // window-title fallback only. - let recognizedLines: [OCRTextHygiene.OCRLine] = observations + let recognized: [(hygiene: OCRTextHygiene.OCRLine, positioned: RecognizedTextLine)] = observations .sorted { if Swift.abs($0.boundingBox.minY - $1.boundingBox.minY) > 0.02 { return $0.boundingBox.minY > $1.boundingBox.minY @@ -135,12 +152,24 @@ struct ScreenTextExtractor: ScreenTextExtracting { return $0.boundingBox.minX < $1.boundingBox.minX } - .compactMap { observation -> OCRTextHygiene.OCRLine? in + .compactMap { observation -> ( + hygiene: OCRTextHygiene.OCRLine, + positioned: RecognizedTextLine + )? in guard let candidate = observation.topCandidates(1).first else { return nil } let trimmed = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - return OCRTextHygiene.OCRLine(text: trimmed, confidence: candidate.confidence) + return ( + OCRTextHygiene.OCRLine(text: trimmed, confidence: candidate.confidence), + RecognizedTextLine( + text: trimmed, + confidence: candidate.confidence, + boundingBox: observation.boundingBox + ) + ) } + let recognizedLines = recognized.map(\.hygiene) + let positionedLines = recognized.map(\.positioned) let joinedText = recognizedLines.map(\.text).joined(separator: "\n") let cappedText = String(joinedText.prefix(maxRecognizedCharacters)) @@ -165,7 +194,8 @@ struct ScreenTextExtractor: ScreenTextExtracting { returning: ExtractedScreenText( text: cappedText, lineCount: recognizedLines.count, - lines: recognizedLines + lines: recognizedLines, + positionedLines: positionedLines ) ) } diff --git a/Cotabby/Support/FoundationModelPromptRenderer.swift b/Cotabby/Support/FoundationModelPromptRenderer.swift index b0f17a74..03824f5c 100644 --- a/Cotabby/Support/FoundationModelPromptRenderer.swift +++ b/Cotabby/Support/FoundationModelPromptRenderer.swift @@ -22,6 +22,34 @@ enum FoundationModelPromptRenderer { /// short, positive, and concrete; the two few-shot examples below carry the rest of the /// anti-drift signal. static func sessionInstructions(for request: SuggestionRequest) -> String { + if let terminalRole = request.context.terminalInputRole { + switch terminalRole { + case .shell: + if request.mode.isTerminalCommandReplacement { + return """ + You translate a plain-English request into one literal macOS shell command. + Output only the complete replacement command: no markdown, prompt symbol, quotes + around the whole command, explanation, repeated request, or newline. + Preserve user-provided names and paths, quoting them safely when needed. + The output is inserted for review and must never include a trailing Return. + """ + } + return """ + You complete a partially typed macOS shell command. + Output only the literal command characters after the caret: no markdown, quotes, explanation, or repeated prefix. + Preserve shell quoting, escaping, casing, options, paths, pipes, and spacing. + Keep the completion to one line. + """ + case .claudeCodeTUI: + return """ + You complete the developer's partially typed request to an AI coding assistant. + Output only the next short continuation after the caret: no greeting, label, quotes, + markdown wrapper, explanation, or repeated prefix. + Match the existing language and tone. + """ + } + } + var lines = [ "You complete partially-typed text. The user is the author; you produce the next " + "few words they would type, in their voice.", @@ -105,6 +133,10 @@ enum FoundationModelPromptRenderer { return "Continue the text at the caret using a short inline completion." } + if let terminalRole = request.context.terminalInputRole { + return terminalPrompt(role: terminalRole, request: request) + } + var sections = [ "Screen context:", "User is on \(request.context.applicationName)." @@ -179,6 +211,41 @@ enum FoundationModelPromptRenderer { return sections.joined(separator: "\n") } + private static func terminalPrompt( + role: TerminalInputRole, + request: SuggestionRequest + ) -> String { + switch role { + case .shell: + let directory = request.context.terminalWorkingDirectory.map { + "Working directory: \($0)\n" + } ?? "" + if request.mode.isTerminalCommandReplacement { + return """ + \(directory)Shell: \(request.context.subrole ?? "shell") + Plain-English request: + \(request.prefixText) + + Return the complete shell command that should replace that request. + """ + } + return """ + \(directory)Shell: \(request.context.subrole ?? "shell") + Exact command before the caret: + \(request.prefixText) + + Return only the command continuation. + """ + case .claudeCodeTUI: + return """ + Exact request text before the caret: + \(request.prefixText) + + Return only the next continuation fragment. + """ + } + } + /// Maps the focused app's surface class to a one-line tone cue or nil if no rule matches. /// Classification lives in the shared `AppSurfaceClassifier` so both engines agree about what /// kind of app the user is in. Terminal emulators and unrecognized apps get no hint: a shell diff --git a/Cotabby/Support/ProcessTreeInspector.swift b/Cotabby/Support/ProcessTreeInspector.swift new file mode 100644 index 00000000..e6440247 --- /dev/null +++ b/Cotabby/Support/ProcessTreeInspector.swift @@ -0,0 +1,75 @@ +import Darwin +import Foundation + +/// Reads process ancestry without launching helper tools or exposing command text. +/// +/// Terminal TUI detection needs to know whether `claude` is running beneath the frontmost +/// terminal host. Keeping that low-level Darwin work here gives the detector a small, pure input +/// (`[String]`) and keeps process-table details out of the UI and focus models. +nonisolated enum ProcessTreeInspector { + static func descendantProcessNames(of parentPid: Int32) -> [String] { + subtreeProcessNames(rootedAt: [parentPid], includingRoots: false) + } + + /// Roots are included because `exec claude` replaces a shell image without changing its PID. + static func subtreeProcessNames( + rootedAt rootPids: [Int32], + includingRoots: Bool = true + ) -> [String] { + let table = processTable() + guard !table.isEmpty else { return [] } + + var byPID: [Int32: kinfo_proc] = [:] + var byParent: [Int32: [kinfo_proc]] = [:] + for process in table { + byPID[process.kp_proc.p_pid] = process + byParent[process.kp_eproc.e_ppid, default: []].append(process) + } + + var names: [String] = [] + if includingRoots { + names.append(contentsOf: rootPids.compactMap { byPID[$0] }.map(processName)) + } + + var pending = rootPids + var visited = Set(rootPids) + while let pid = pending.popLast() { + for child in byParent[pid] ?? [] where visited.insert(child.kp_proc.p_pid).inserted { + pending.append(child.kp_proc.p_pid) + let name = processName(child) + if !name.isEmpty { names.append(name) } + } + } + return names + } + + private static func processName(_ process: kinfo_proc) -> String { + withUnsafePointer(to: process.kp_proc.p_comm) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN) + 1) { + String(cString: $0) + } + } + } + + private static func processTable() -> [kinfo_proc] { + var query: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + var byteCount: size_t = 0 + let queryCount = u_int(query.count) + guard sysctl(&query, queryCount, nil, &byteCount, nil, 0) == 0, + byteCount > 0 else { return [] } + + // Process creation can race the size/read pair. A small amount of spare capacity avoids + // treating that ordinary race as a negative Claude Code classification. + let elementSize = MemoryLayout.size + var buffer = [kinfo_proc]( + repeating: kinfo_proc(), + count: (byteCount / elementSize) + 16 + ) + byteCount = buffer.count * elementSize + let result = buffer.withUnsafeMutableBufferPointer { + sysctl(&query, queryCount, $0.baseAddress, &byteCount, nil, 0) + } + guard result == 0 else { return [] } + return Array(buffer.prefix(byteCount / elementSize)) + } +} diff --git a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift index e7c409b1..8893e98f 100644 --- a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift +++ b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift @@ -13,6 +13,7 @@ enum SuggestionAvailabilityEvaluator { disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, + terminalIntegrationActive: Bool = false, inputMonitoringGranted: Bool, focusSnapshot: FocusSnapshot, checkCapability: Bool = true @@ -40,16 +41,12 @@ enum SuggestionAvailabilityEvaluator { return "Cotabby is disabled on \(host)." } - if TerminalAppDetector.isTerminal(bundleIdentifier: focusSnapshot.bundleIdentifier) { - return "Cotabby is not available in terminal apps." - } - - // Integrated terminals (VS Code / Cursor xterm.js) share their app's bundle id with the - // editor and chat, so they slip past the blocklist above. Suppress them here unless the user - // has opted back in, keeping ghost text out of shell prompts and command output while the - // editor and Copilot chat in the same window keep suggesting. - if !suggestInIntegratedTerminals, focusSnapshot.context?.isIntegratedTerminal == true { - return "Cotabby is not available in the integrated terminal." + if let terminalReason = terminalDisabledReason( + focusSnapshot: focusSnapshot, + isOptedIn: suggestInIntegratedTerminals, + integrationActive: terminalIntegrationActive + ) { + return terminalReason } guard inputMonitoringGranted else { @@ -74,6 +71,7 @@ enum SuggestionAvailabilityEvaluator { disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, + terminalIntegrationActive: Bool = false, inputMonitoringGranted: Bool, focusSnapshot: FocusSnapshot ) -> Bool { @@ -83,6 +81,7 @@ enum SuggestionAvailabilityEvaluator { disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActive, inputMonitoringGranted: inputMonitoringGranted, focusSnapshot: focusSnapshot ) == nil @@ -106,6 +105,7 @@ enum SuggestionAvailabilityEvaluator { disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], suggestInIntegratedTerminals: Bool = false, + terminalIntegrationActive: Bool = false, inputMonitoringGranted: Bool, screenRecordingGranted: Bool, focusSnapshot: FocusSnapshot, @@ -119,12 +119,20 @@ enum SuggestionAvailabilityEvaluator { return false } + // Terminal sources own their own narrow OCR lifecycle. Feeding their pixels through the + // generic visual-context prompt path would duplicate capture and leak scrollback into the + // model in addition to the purpose-built shell/TUI context. + guard focusSnapshot.context?.terminalInputRole == nil else { + return false + } + return disabledReason( globallyEnabled: globallyEnabled, temporarilyPaused: temporarilyPaused, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, suggestInIntegratedTerminals: suggestInIntegratedTerminals, + terminalIntegrationActive: terminalIntegrationActive, inputMonitoringGranted: inputMonitoringGranted, focusSnapshot: focusSnapshot, checkCapability: false @@ -144,4 +152,23 @@ enum SuggestionAvailabilityEvaluator { return SuggestionRequestFactory.shouldGenerateSuggestion(for: context.precedingText) } + + private static func terminalDisabledReason( + focusSnapshot: FocusSnapshot, + isOptedIn: Bool, + integrationActive: Bool + ) -> String? { + let hasTerminalRole = focusSnapshot.context?.terminalInputRole != nil + let isTerminalSurface = TerminalAppDetector.isTerminal( + bundleIdentifier: focusSnapshot.bundleIdentifier + ) || focusSnapshot.context?.isIntegratedTerminal == true || hasTerminalRole + guard isTerminalSurface else { return nil } + guard isOptedIn else { return "Terminal autocomplete is turned off." } + // The opt-in is only a master switch. Exact hook data or verified Claude Code OCR must own + // effective focus before terminal text becomes eligible. + guard integrationActive, hasTerminalRole else { + return "Terminal autocomplete is waiting for a live shell or Claude Code source." + } + return nil + } } diff --git a/Cotabby/Support/SuggestionRequestFactory.swift b/Cotabby/Support/SuggestionRequestFactory.swift index 48190318..7f305d36 100644 --- a/Cotabby/Support/SuggestionRequestFactory.swift +++ b/Cotabby/Support/SuggestionRequestFactory.swift @@ -36,36 +36,56 @@ enum SuggestionRequestFactory { clipboardContext: String? = nil, visualContextSummary: String? = nil ) -> SuggestionRequestBuildResult { - let prefixText = truncatedPromptPrefix( - from: context.precedingText, - configuration: configuration, - engine: settings.selectedEngine - ) + let terminalRole = context.terminalInputRole + let requestMode: SuggestionRequestMode = if terminalRole == .shell, + context.trailingText.isEmpty, + TerminalCommandIntentPolicy.isReplacementIntent( + context.precedingText + ) { + .terminalCommandReplacement(originalText: context.precedingText) + } else { + .continuation + } + let prefixText = if terminalRole == nil { + truncatedPromptPrefix( + from: context.precedingText, + configuration: configuration, + engine: settings.selectedEngine + ) + } else { + // Shell spacing, quoting, and path separators are semantic. Preserve the exact tail + // instead of the prose truncator's word split/join normalization. + String(context.precedingText.suffix(configuration.maxPrefixCharacters)) + } let completionLengthInstruction = settings.effectiveWordRange.promptInstruction - let userName = activeUserName(settings: settings) + let userName = terminalRole == nil ? activeUserName(settings: settings) : nil // Custom rules are hidden from users (CustomRulesCatalog.isUserFacingEnabled == false): the // base-model OSS path cannot obey free-text instructions and the rule text leaks into output, // so injection is suppressed on every engine. Stored rules survive untouched, so flipping the // flag restores this. When enabled, the value is already normalized (trimmed/deduped/capped) // by SuggestionSettingsModel.setRules. - let customRules = CustomRulesCatalog.isUserFacingEnabled ? settings.customRules : [] + let customRules = terminalRole == nil && CustomRulesCatalog.isUserFacingEnabled + ? settings.customRules + : [] // The settings model length-caps but does NOT trim whitespace (trimming on every keystroke // would prevent the user from typing a space at the end of a word in the editor). Do the // trim here, once per request, and collapse a whitespace-only body back to nil so renderers // skip the section heading entirely. let trimmedExtendedContext = settings.extendedContext .trimmingCharacters(in: .whitespacesAndNewlines) - let activeExtendedContext = trimmedExtendedContext.isEmpty ? nil : trimmedExtendedContext + let activeExtendedContext = terminalRole == nil && !trimmedExtendedContext.isEmpty + ? trimmedExtendedContext + : nil // nil when the user declared no languages — the renderers then just match the surrounding text. - let languageInstruction = LanguageCatalog.promptInstruction(for: settings.responseLanguages) - let boundedClipboardContext = activeClipboardContext( - rawContext: clipboardContext, - settings: settings, - prefixText: prefixText - ) - let boundedVisualContextSummary = activeVisualContextSummary( - rawSummary: visualContextSummary - ) + let languageInstruction = terminalRole == nil + ? LanguageCatalog.promptInstruction(for: settings.responseLanguages) + : nil + let boundedClipboardContext = terminalRole == nil + ? activeClipboardContext(rawContext: clipboardContext, settings: settings, prefixText: prefixText) + : nil + let boundedVisualContextSummary = terminalRole == nil + ? activeVisualContextSummary(rawSummary: visualContextSummary) + : nil // The composed surface description; nil when the user disabled it or the surface class // suppresses it (code editors, terminals, anonymous generic apps). The composer sanitizes // titles/placeholders and reduces the URL to a bare domain before anything reaches a prompt. @@ -86,30 +106,46 @@ enum SuggestionRequestFactory { // Custom instructions and persona condition the output rather than being obeyed. The // Foundation Models path builds its own messages from these same request fields, so this // prompt string is only consumed by the llama engine. - let prompt = BaseCompletionPromptRenderer.prompt( - prefixText: prefixText, - applicationName: context.applicationName, - userName: userName, - customRules: customRules, - extendedContext: activeExtendedContext, - languageInstruction: languageInstruction, - clipboardContext: boundedClipboardContext, - visualContextSummary: boundedVisualContextSummary, - surfaceContext: surfaceContext, - tokenBudget: configuration.llamaPromptTokenBudget - ) + let prompt = if let terminalRole { + TerminalCompletionPromptRenderer.prompt( + prefixText: prefixText, + role: terminalRole, + shellName: context.subrole, + workingDirectory: context.terminalWorkingDirectory, + mode: requestMode + ) + } else { + BaseCompletionPromptRenderer.prompt( + prefixText: prefixText, + applicationName: context.applicationName, + userName: userName, + customRules: customRules, + extendedContext: activeExtendedContext, + languageInstruction: languageInstruction, + clipboardContext: boundedClipboardContext, + visualContextSummary: boundedVisualContextSummary, + surfaceContext: surfaceContext, + tokenBudget: configuration.llamaPromptTokenBudget + ) + } let request = SuggestionRequest( context: context, prefixText: prefixText, prompt: prompt, generation: context.generation, - maxPredictionTokens: activeMaxPredictionTokens( - configuration: configuration, - wordRange: settings.effectiveWordRange, - responseLanguages: settings.responseLanguages, - isMultiLineEnabled: settings.isMultiLineEnabled - ), + maxPredictionTokens: { + let normalBudget = activeMaxPredictionTokens( + configuration: configuration, + wordRange: settings.effectiveWordRange, + responseLanguages: settings.responseLanguages, + isMultiLineEnabled: terminalRole == .shell ? false : settings.isMultiLineEnabled + ) + // A translated command may need flags, quoting, and a path even when the user's + // normal prose setting is only 2-4 words. The one-line normalizer remains the hard + // output boundary; this floor prevents truncating a command mid-token. + return requestMode.isTerminalCommandReplacement ? max(normalBudget, 32) : normalBudget + }(), temperature: configuration.temperature, topK: configuration.topK, topP: configuration.topP, @@ -125,8 +161,9 @@ enum SuggestionRequestFactory { clipboardContext: boundedClipboardContext, visualContextSummary: boundedVisualContextSummary, surfaceContext: surfaceContext, - isMultiLineEnabled: settings.isMultiLineEnabled, - requestID: RequestID.generate() + isMultiLineEnabled: terminalRole == .shell ? false : settings.isMultiLineEnabled, + requestID: RequestID.generate(), + mode: requestMode ) return SuggestionRequestBuildResult( diff --git a/Cotabby/Support/SuggestionSessionReconciler.swift b/Cotabby/Support/SuggestionSessionReconciler.swift index 7d01f94a..487d55a4 100644 --- a/Cotabby/Support/SuggestionSessionReconciler.swift +++ b/Cotabby/Support/SuggestionSessionReconciler.swift @@ -61,6 +61,11 @@ enum SuggestionSessionReconciler { return .invalid("Overlay hidden because the focused field changed.") } + if liveContext.terminalInputRole != nil || session.baseContext.terminalInputRole != nil, + liveContext.elementIdentifier != session.baseContext.elementIdentifier { + return .invalid("Overlay hidden because the terminal input source changed.") + } + guard liveContext.selection.length == 0 else { return .invalid("Overlay hidden because text is selected.") } diff --git a/Cotabby/Support/SuggestionTextNormalizer.swift b/Cotabby/Support/SuggestionTextNormalizer.swift index 8a90df82..e17ba8a6 100644 --- a/Cotabby/Support/SuggestionTextNormalizer.swift +++ b/Cotabby/Support/SuggestionTextNormalizer.swift @@ -107,6 +107,9 @@ enum SuggestionTextNormalizer { // template so instructions never read as content in the first place. normalized = stripLeadingScaffoldingLabels(normalized) normalized = normalized.trimmingCharacters(in: .newlines) + if request.mode.isTerminalCommandReplacement { + normalized = stripTerminalCommandScaffolding(normalized) + } if request.isMultiLineEnabled { // Multi-line mode: keep content up to the first blank-line boundary (double newline) @@ -210,6 +213,29 @@ enum SuggestionTextNormalizer { return result } + /// Removes wrappers that chat-shaped endpoints sometimes add around a requested shell command. + /// This runs only for explicit terminal replacement requests, so ordinary prose containing a + /// `Command:` label or fenced code remains untouched by autocomplete normalization. + private static func stripTerminalCommandScaffolding(_ text: String) -> String { + var working = text.trimmingCharacters(in: .whitespacesAndNewlines) + if working.hasPrefix("```") { + var lines = working.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + if !lines.isEmpty { lines.removeFirst() } + if lines.last?.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") == true { + lines.removeLast() + } + working = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + if working.range(of: "Command:", options: [.caseInsensitive, .anchored]) != nil { + working = String(working.dropFirst("Command:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if working.hasPrefix("$ ") { + working.removeFirst(2) + } + return working + } + /// Finds the longest suffix of `precedingText` (at any word offset) that matches a prefix /// of `suggestion`, then strips that overlap. Returns empty if the entire suggestion is echoed. /// diff --git a/Cotabby/Support/TerminalAppDetector.swift b/Cotabby/Support/TerminalAppDetector.swift index 336613a2..491f1182 100644 --- a/Cotabby/Support/TerminalAppDetector.swift +++ b/Cotabby/Support/TerminalAppDetector.swift @@ -19,11 +19,33 @@ nonisolated enum TerminalAppDetector { "io.rio.terminal" ] + /// Apps that can host a shell pane while also exposing ordinary AX text fields in the same + /// process. They receive terminal treatment only while an authoritative hook/TUI source is + /// active; the editor and command palette continue through normal AX capture. + private static let embeddedTerminalHostBundleIdentifiers: Set = [ + "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + "com.todesktop.230313mzl4w4u92", // Cursor + "com.exafunction.windsurf", + "dev.zed.Zed", + "com.jetbrains.intellij" + ] + static func isTerminal(bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return terminalBundleIdentifiers.contains(bundleIdentifier) } + static func hostsEmbeddedTerminal(bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier else { return false } + return embeddedTerminalHostBundleIdentifiers.contains(bundleIdentifier) + } + + static func isTerminalHost(bundleIdentifier: String?) -> Bool { + isTerminal(bundleIdentifier: bundleIdentifier) + || hostsEmbeddedTerminal(bundleIdentifier: bundleIdentifier) + } + /// DOM class prefix xterm.js stamps on every node of its terminal subtree — most importantly the /// focusable `xterm-helper-textarea` that receives the caret. private static let integratedTerminalClassPrefix = "xterm" diff --git a/Cotabby/Support/TerminalCommandIntentPolicy.swift b/Cotabby/Support/TerminalCommandIntentPolicy.swift new file mode 100644 index 00000000..86d5a249 --- /dev/null +++ b/Cotabby/Support/TerminalCommandIntentPolicy.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Recognizes the narrow shell-buffer shape that should be translated rather than continued. +/// +/// This is deliberately a deterministic preflight, not an LLM classification request. Cotabby only +/// enters replacement mode when the whole line starts with a plain-English imperative that is not a +/// normal macOS executable. Ordinary commands such as `git status`, paths, flags, pipes, redirects, +/// and partially typed command names remain on the existing continuation path. +nonisolated enum TerminalCommandIntentPolicy { + private static let naturalLanguageVerbs: Set = [ + "change", "compress", "copy", "count", "create", "delete", "display", "download", + "extract", "go", "install", "list", "make", "move", "remove", "rename", "search", + "show", "start", "stop", "uninstall" + ] + + private static let shellSyntaxFragments = ["&&", "||", "|", ";", ">", "<", "$(", "`"] + + static func isReplacementIntent(_ text: String) -> Bool { + let candidate = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !candidate.isEmpty, + candidate.count <= 240, + !candidate.contains("\n"), + !candidate.contains("\0"), + !shellSyntaxFragments.contains(where: candidate.contains) + else { return false } + + let words = candidate.split(whereSeparator: \.isWhitespace) + guard words.count >= 2, words.count <= 24, + let first = words.first?.lowercased(), + naturalLanguageVerbs.contains(first) + else { return false } + + let commandLikePrefixes = ["./", "../", "/", "~/", "-", "$", "."] + return !commandLikePrefixes.contains(where: { candidate.hasPrefix($0) }) + } +} diff --git a/Cotabby/Support/TerminalCompletionPromptRenderer.swift b/Cotabby/Support/TerminalCompletionPromptRenderer.swift new file mode 100644 index 00000000..37ffcb01 --- /dev/null +++ b/Cotabby/Support/TerminalCompletionPromptRenderer.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Renders continuation-shaped prompts for terminal input sources. +/// +/// A shell command is not prose: feeding `git ch` after Cotabby's normal authorship preface makes a +/// base model continue the preface instead of the command. The fake transcript below establishes +/// the document form while leaving the user's exact prefix as the final bytes. Claude Code's input +/// box is natural language, so it receives a lightweight coding-assistant message frame instead. +enum TerminalCompletionPromptRenderer { + static func prompt( + prefixText: String, + role: TerminalInputRole, + shellName: String?, + workingDirectory: String?, + mode: SuggestionRequestMode = .continuation + ) -> String { + switch role { + case .shell: + if mode.isTerminalCommandReplacement { + return commandReplacementPrompt( + instruction: prefixText, + shellName: shellName, + workingDirectory: workingDirectory + ) + } + let shell = shellName?.nonEmpty ?? "shell" + let directory = compactDirectory(workingDirectory) + let locationLine = directory.map { "Working directory: \($0)\n" } ?? "" + return """ + Transcript of a \(shell) session on macOS. + \(locationLine)$ cd ~/projects + $ ls -la + $ git status + $ \(prefixText) + """ + case .claudeCodeTUI: + return """ + A developer is typing a request to an AI coding assistant. Continue the message naturally. + + \(prefixText) + """ + } + } + + /// Base models learn the translation shape from examples instead of an instruction-heavy chat + /// preamble. The user's exact sentence is last so generation begins immediately after `Command:`. + private static func commandReplacementPrompt( + instruction: String, + shellName: String?, + workingDirectory: String? + ) -> String { + let shell = shellName?.nonEmpty ?? "shell" + let directory = compactDirectory(workingDirectory).map { "Working directory: \($0)\n" } ?? "" + return """ + Plain-English requests translated into one literal \(shell) command on macOS. + \(directory)Instruction: list all files including hidden files + Command: ls -la + Instruction: create a folder named reports + Command: mkdir -- reports + Instruction: delete folder named old-build + Command: rm -rf -- old-build + Instruction: \(instruction) + Command: + """ + } + + private static func compactDirectory(_ raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let components = URL(fileURLWithPath: raw).pathComponents.suffix(3) + return components.joined(separator: "/").nonEmpty + } +} + +private nonisolated extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/Cotabby/Support/TerminalFocusAdapter.swift b/Cotabby/Support/TerminalFocusAdapter.swift new file mode 100644 index 00000000..32c0bcfc --- /dev/null +++ b/Cotabby/Support/TerminalFocusAdapter.swift @@ -0,0 +1,66 @@ +import CoreGraphics +import Foundation + +/// Adapts cooperative shell state into the focus shape the suggestion pipeline already consumes. +/// +/// The adapter is pure: socket lifecycle, app matching, and screen capture stay in services. A +/// zero caret is intentional while the OCR prompt anchor is unresolved; presentation suppresses +/// the overlay rather than painting a plausible-looking suggestion over unrelated terminal output. +enum TerminalFocusAdapter { + static func adapt( + _ snapshot: TerminalFocusSnapshot, + terminalPid: Int32, + focusChangeSequence: UInt64 = 0 + ) -> FocusedInputSnapshot { + FocusedInputSnapshot( + applicationName: applicationName(for: snapshot.terminalBundleIdentifier), + bundleIdentifier: snapshot.terminalBundleIdentifier, + processIdentifier: terminalPid, + elementIdentifier: snapshot.sessionIdentity.elementIdentifier, + role: TerminalInputRole.shell.rawValue, + subrole: snapshot.shellType.rawValue, + caretRect: snapshot.estimatedCursorRect ?? .zero, + inputFrameRect: snapshot.promptLineRect ?? snapshot.terminalWindowFrame, + caretSource: "TerminalShellIntegration", + caretQuality: .estimated, + observedCharWidth: snapshot.observedCellWidth ?? TerminalGeometryResolver.defaultCellMetrics.cellWidth, + precedingText: snapshot.precedingText, + trailingText: snapshot.trailingText, + selection: NSRange(location: snapshot.cursorCharacterOffset, length: 0), + isSecure: false, + isIntegratedTerminal: TerminalAppDetector.hostsEmbeddedTerminal( + bundleIdentifier: snapshot.terminalBundleIdentifier + ), + focusChangeSequence: focusChangeSequence, + resolvedFieldStyle: ResolvedFieldStyle( + fontName: "Menlo-Regular", + fontPointSize: 13, + colorHex: nil + ), + windowTitle: snapshot.workingDirectory, + terminalWorkingDirectory: snapshot.workingDirectory, + terminalTTY: snapshot.tty, + sourceRevision: snapshot.sourceRevision + ) + } + + private static func applicationName(for bundleIdentifier: String) -> String { + applicationNames[bundleIdentifier] ?? bundleIdentifier + } + + private static let applicationNames = [ + "com.mitchellh.ghostty": "Ghostty", + "com.apple.Terminal": "Terminal", + "com.googlecode.iterm2": "iTerm2", + "net.kovidgoyal.kitty": "Kitty", + "io.alacritty": "Alacritty", + "co.zeit.hyper": "Hyper", + "dev.warp.Warp-Stable": "Warp", + "com.github.wez.wezterm": "WezTerm", + "io.rio.terminal": "Rio", + "com.microsoft.VSCode": "VS Code", + "com.microsoft.VSCodeInsiders": "VS Code Insiders", + "com.todesktop.230313mzl4w4u92": "Cursor", + "dev.zed.Zed": "Zed" + ] +} diff --git a/Cotabby/Support/TerminalPromptAnchor.swift b/Cotabby/Support/TerminalPromptAnchor.swift new file mode 100644 index 00000000..88f354c6 --- /dev/null +++ b/Cotabby/Support/TerminalPromptAnchor.swift @@ -0,0 +1,162 @@ +import CoreGraphics +import Foundation + +/// Cached relationship between a shell command buffer and its rendered terminal row. +/// +/// OCR establishes the row once; subsequent shell-hook updates move the caret arithmetically from +/// the exact cursor offset. This keeps OCR out of the per-keystroke hot path while refusing to draw +/// when the prompt cannot be matched confidently. +nonisolated struct TerminalPromptAnchor: Equatable, Sendable { + let sessionIdentity: TerminalSessionIdentity + let windowFrame: CGRect + let captureRegion: CGRect + let promptLineRect: CGRect + let cellWidth: CGFloat + let cellHeight: CGFloat + let bufferStartX: CGFloat + let columnCount: Int + let wasEmptyBuffer: Bool + let capturedAt: Date +} + +nonisolated enum TerminalPromptAnchorResolver { + static let maximumAge: TimeInterval = 20 + private static let cellWidthRange: ClosedRange = 4...18 + private static let promptTerminators: Set = [">", "%", "$", "#"] + + static func makeAnchor( + snapshot: TerminalFocusSnapshot, + lines: [RecognizedTextLine], + captureRegion: CGRect, + windowFrame: CGRect, + now: Date = Date() + ) -> TerminalPromptAnchor? { + guard let match = match(buffer: snapshot.commandBuffer, in: lines) else { return nil } + let line = lines[match.lineIndex] + let lineCharacterCount = max(line.text.count, 1) + let lineRect = CGRect( + x: captureRegion.minX + line.boundingBox.minX * captureRegion.width, + y: captureRegion.minY + (1 - line.boundingBox.maxY) * captureRegion.height, + width: line.boundingBox.width * captureRegion.width, + height: max(line.boundingBox.height * captureRegion.height, 12) + ) + let cellWidth = lineRect.width / CGFloat(lineCharacterCount) + guard cellWidthRange.contains(cellWidth) else { return nil } + + let startX = match.rawBufferStart.map { + lineRect.minX + CGFloat($0) * cellWidth + } ?? (lineRect.maxX + cellWidth) + return TerminalPromptAnchor( + sessionIdentity: snapshot.sessionIdentity, + windowFrame: windowFrame, + captureRegion: captureRegion, + promptLineRect: lineRect, + cellWidth: cellWidth, + cellHeight: lineRect.height, + bufferStartX: startX, + columnCount: max(Int(captureRegion.width / cellWidth), 20), + wasEmptyBuffer: snapshot.commandBuffer.trimmingCharacters(in: .whitespaces).isEmpty, + capturedAt: now + ) + } + + static func geometry( + cursorOffset: Int, + anchor: TerminalPromptAnchor + ) -> (caret: CGRect, input: CGRect) { + let startColumn = max( + Int(((anchor.bufferStartX - anchor.captureRegion.minX) / anchor.cellWidth).rounded()), + 0 + ) + let linearColumn = startColumn + max(cursorOffset, 0) + let row = linearColumn / anchor.columnCount + let column = linearColumn % anchor.columnCount + let caret = CGRect( + x: anchor.captureRegion.minX + CGFloat(column) * anchor.cellWidth, + y: anchor.promptLineRect.minY + CGFloat(row) * anchor.cellHeight, + width: anchor.cellWidth, + height: anchor.cellHeight + ) + let input = CGRect( + x: anchor.captureRegion.minX, + y: caret.minY, + width: anchor.captureRegion.width, + height: anchor.cellHeight + ) + return (caret, input) + } + + static func isValid( + _ anchor: TerminalPromptAnchor, + windowFrame: CGRect?, + cursorOffset: Int, + now: Date = Date() + ) -> Bool { + guard now.timeIntervalSince(anchor.capturedAt) <= maximumAge else { return false } + if let windowFrame { + guard abs(windowFrame.minX - anchor.windowFrame.minX) <= 1, + abs(windowFrame.minY - anchor.windowFrame.minY) <= 1, + abs(windowFrame.width - anchor.windowFrame.width) <= 1, + abs(windowFrame.height - anchor.windowFrame.height) <= 1 else { return false } + } + let caret = geometry(cursorOffset: cursorOffset, anchor: anchor).caret + return anchor.windowFrame.insetBy(dx: -2, dy: -2).contains( + CGPoint(x: caret.midX, y: caret.midY) + ) + } + + private struct Match { + let lineIndex: Int + let rawBufferStart: Int? + } + + private struct CandidateMatch { + let lineIndex: Int + let rawBufferStart: Int + let minimumY: CGFloat + } + + private static func match(buffer: String, in lines: [RecognizedTextLine]) -> Match? { + guard !lines.isEmpty else { return nil } + let trimmed = buffer.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + let candidates = lines.enumerated().filter { + guard let last = folded($0.element.text).last else { return false } + return promptTerminators.contains(last) + } + let pool = candidates.isEmpty ? Array(lines.enumerated()) : candidates + return pool.min(by: { $0.element.boundingBox.minY < $1.element.boundingBox.minY }) + .map { Match(lineIndex: $0.offset, rawBufferStart: nil) } + } + + let needleLengths = [min(trimmed.count, 24), min(trimmed.count, 10)] + for length in needleLengths where length > 0 { + let needle = String(trimmed.prefix(length)) + let matches = lines.enumerated().compactMap { index, line -> CandidateMatch? in + guard let range = line.text.range(of: needle) else { return nil } + let rawStart = line.text.distance(from: line.text.startIndex, to: range.lowerBound) + return CandidateMatch( + lineIndex: index, + rawBufferStart: rawStart, + minimumY: line.boundingBox.minY + ) + } + if let bottomMost = matches.min(by: { $0.minimumY < $1.minimumY }) { + return Match( + lineIndex: bottomMost.lineIndex, + rawBufferStart: bottomMost.rawBufferStart + ) + } + } + return nil + } + + private static func folded(_ value: String) -> String { + value.trimmingCharacters(in: .whitespaces).map { character in + switch character { + case "❯", "›", "»", "➜": ">" + default: character + } + }.reduce(into: "") { $0.append($1) } + } +} diff --git a/Cotabby/Support/TuiFocusAdapter.swift b/Cotabby/Support/TuiFocusAdapter.swift new file mode 100644 index 00000000..eab6a246 --- /dev/null +++ b/Cotabby/Support/TuiFocusAdapter.swift @@ -0,0 +1,46 @@ +import CoreGraphics +import Foundation + +/// Adapts pixel-sourced Claude Code input into the suggestion pipeline's focus value. +/// +/// Including the ScreenCaptureKit window id in the element identity keeps two terminal windows in +/// the same process from sharing an autocomplete session or cached completion. +enum TuiFocusAdapter { + static func adapt( + reading: TuiContextReader.PromptReading, + capture: TerminalWindowCapture, + caretRect: CGRect, + inputFrameRect: CGRect, + sourceRevision: UInt64, + focusChangeSequence: UInt64 = 0 + ) -> FocusedInputSnapshot { + FocusedInputSnapshot( + applicationName: capture.descriptor.applicationName, + bundleIdentifier: capture.descriptor.bundleIdentifier, + processIdentifier: capture.descriptor.pid, + elementIdentifier: "terminal-tui-claude-\(capture.descriptor.pid)-\(capture.descriptor.windowID)", + role: TerminalInputRole.claudeCodeTUI.rawValue, + subrole: "OCR", + caretRect: caretRect, + inputFrameRect: inputFrameRect, + caretSource: "ClaudeCodeTuiOCR", + caretQuality: .estimated, + observedCharWidth: TerminalGeometryResolver.defaultCellMetrics.cellWidth, + precedingText: reading.promptText, + trailingText: "", + selection: NSRange(location: reading.estimatedCursorOffset, length: 0), + isSecure: false, + isIntegratedTerminal: TerminalAppDetector.hostsEmbeddedTerminal( + bundleIdentifier: capture.descriptor.bundleIdentifier + ), + focusChangeSequence: focusChangeSequence, + resolvedFieldStyle: ResolvedFieldStyle( + fontName: "Menlo-Regular", + fontPointSize: 13, + colorHex: nil + ), + windowTitle: capture.descriptor.title, + sourceRevision: sourceRevision + ) + } +} diff --git a/Cotabby/Support/TuiSessionDetector.swift b/Cotabby/Support/TuiSessionDetector.swift new file mode 100644 index 00000000..a7a8f967 --- /dev/null +++ b/Cotabby/Support/TuiSessionDetector.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Cheaply decides whether the frontmost terminal may be hosting Claude Code. +/// +/// Process and title signals deliberately precede OCR. A positive signal starts capture quickly; +/// an inconclusive terminal still falls back to OCR because sandboxed/launchd-owned process trees +/// often expose only the terminal host, not the TUI beneath it. Pixel-level confirmation remains +/// the authority before a focus snapshot is used, so an ordinary shell cannot become eligible just +/// because its process tree was inconclusive. +nonisolated enum TuiSessionDetector { + enum Classification: Equatable, Sendable { + case claudeCode + case notClaudeCode + case unknown + } + + private static let processNames: Set = ["claude", "claude-code"] + private static let titleMarkers = ["Claude Code", "claude-code", " claude "] + + static func classification( + bundleIdentifier: String?, + terminalAccessibilityTitle: String?, + foregroundProcessNames: () -> [String] + ) -> Classification { + guard TerminalAppDetector.isTerminalHost(bundleIdentifier: bundleIdentifier) else { + return .notClaudeCode + } + + if let title = terminalAccessibilityTitle { + let paddedTitle = " \(title) " + if titleMarkers.contains(where: { + paddedTitle.range(of: $0, options: .caseInsensitive) != nil + }) { + return .claudeCode + } + } + + let names = foregroundProcessNames().map { $0.lowercased() } + if names.contains(where: processNames.contains) { + return .claudeCode + } + // A negative process lookup is not authoritative on macOS. Terminal children may be + // launchd-owned, and without a sourced shell hook Cotabby has no shell PID from which to + // traverse. Returning `.unknown` lets the narrow window OCR check for Claude Code chrome; + // that reader still requires both a stable screen fingerprint and an editable prompt line. + return .unknown + } +} diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index 6cf02411..b136cc1a 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -39,17 +39,21 @@ struct AppsPaneView: View { } } - Section("Integrated Terminals") { + Section("Terminal Autocomplete") { Toggle(isOn: suggestInIntegratedTerminalsBinding) { SettingsRowLabel( - title: "Suggest in Integrated Terminals", - description: "Show ghost text in VS Code and Cursor integrated terminals. " - + "Off by default so suggestions stay out of shell prompts; the editor " - + "and chat in the same window keep suggesting either way.", + title: "Terminal Autocomplete (Beta)", + description: "Show source-verified ghost text in dedicated and integrated " + + "terminals. Shell prompts use a local hook; Claude Code uses on-device " + + "screen OCR. Off by default.", systemImage: "terminal" ) } .settingsItem(.suggestInIntegratedTerminals) + + if suggestionSettings.suggestInIntegratedTerminals { + terminalSetupInstructions + } } if !filteredRunningAppSuggestions.isEmpty { @@ -78,6 +82,70 @@ struct AppsPaneView: View { ) } + /// The app installs signed hook copies under Application Support when the beta starts. Settings + /// only presents explicit source commands—it never mutates a shell startup file behind the + /// user's back, because ordering and existing plugin managers are shell-specific decisions. + @ViewBuilder + private var terminalSetupInstructions: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Add the command for your shell to its startup file, then open a new terminal. " + + "Screen Recording permission is used only to locate the visible prompt and read " + + "Claude Code's input box; command text and OCR stay on this Mac.") + .font(.caption) + .foregroundStyle(.secondary) + + terminalCommandRow(shell: .zsh, startupFile: "~/.zshrc") + terminalCommandRow(shell: .bash, startupFile: "~/.bashrc (Bash 4+)") + terminalCommandRow(shell: .fish, startupFile: "~/.config/fish/conf.d/cotabby.fish") + + Text("The Bash and fish hooks wrap printable-key bindings and may conflict with custom " + + "line-editor bindings. zsh uses its non-invasive redraw hook and is recommended.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.leading, 28) + } + + @ViewBuilder + private func terminalCommandRow(shell: ShellType, startupFile: String) -> some View { + let command = setupCommand(for: shell) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("\(shell.rawValue) · \(startupFile)") + .font(.caption.weight(.medium)) + Spacer() + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy \(shell.rawValue) terminal setup command") + } + Text(command) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(3) + } + .padding(8) + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 7)) + } + + private func setupCommand(for shell: ShellType) -> String { + let paths = TerminalIntegrationPaths.current() + let socket = shellQuoted(paths.socketURL.path) + let hook = shellQuoted(paths.hookURL(for: shell).path) + switch shell { + case .zsh, .bash: + return "export COTABBY_SOCKET_PATH=\(socket)\nsource \(hook)" + case .fish: + return "set -gx COTABBY_SOCKET_PATH \(socket)\nsource \(hook)" + } + } + + private func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + /// Hide suggestions that are already in the disabled list so the row never shows a /// no-op chip. Recomputed on every redraw because `disabledAppRules` is observed. private var filteredRunningAppSuggestions: [RunningAppSuggestion] { diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 35e1e39c..c57f122d 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -154,7 +154,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptEntireSuggestion: return "Accept Entire Suggestion" case .toggleTabby: return "Toggle Cotabby" case .disabledApps: return "Disabled Apps" - case .suggestInIntegratedTerminals: return "Suggest in Integrated Terminals" + case .suggestInIntegratedTerminals: return "Terminal Autocomplete (Beta)" case .accessibility: return "Accessibility" case .inputMonitoring: return "Input Monitoring" case .screenRecording: return "Screen Recording" @@ -341,7 +341,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." case .toggleTabby: return "A global hotkey that turns Cotabby on or off." case .disabledApps: return "Apps where Cotabby never autocompletes." - case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." + case .suggestInIntegratedTerminals: + return "Source-verified shell and Claude Code ghost text in terminal apps." case .accessibility: return "Required to read the focused field and caret." case .inputMonitoring: return "Required to see keystrokes and the accept key." case .screenRecording: return "Optional visual context from the focused window." @@ -534,8 +535,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { "deny list", "exception", "app exclusion", "skip", "off in"] case .suggestInIntegratedTerminals: return ["terminal", "terminals", "integrated terminal", "vscode", "vs code", - "cursor", "shell", "xterm", "command line", "cli", "console", - "ghost text in terminal"] + "cursor", "shell", "zsh", "bash", "fish", "claude", "claude code", + "xterm", "command line", "cli", "console", "ghost text in terminal"] case .accessibility: return ["accessibility", "ax", "permission", "access", "system settings", "privacy", "grant", "allow"] diff --git a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift index 24461f17..6970f215 100644 --- a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift @@ -80,6 +80,24 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { ) } + private func makeAuthoritativeTerminalSnapshot() -> FocusSnapshot { + let context = CotabbyTestFixtures.focusedInputSnapshot( + applicationName: "Code", + bundleIdentifier: "com.microsoft.VSCode", + elementIdentifier: "terminal-shell-123-session", + role: TerminalInputRole.shell.rawValue, + subrole: ShellType.zsh.rawValue, + isIntegratedTerminal: true + ) + return FocusSnapshot( + applicationName: "Code", + bundleIdentifier: "com.microsoft.VSCode", + capability: .supported, + context: context, + inspection: nil + ) + } + // MARK: - Integrated terminal gating func test_disabledReason_integratedTerminal_suppressedByDefault() { @@ -89,10 +107,10 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { focusSnapshot: makeIntegratedTerminalSnapshot() ) - XCTAssertEqual(reason, "Cotabby is not available in the integrated terminal.") + XCTAssertEqual(reason, "Terminal autocomplete is turned off.") } - func test_disabledReason_integratedTerminal_allowedWhenOptedIn() { + func test_disabledReason_optInAloneDoesNotAuthorizeRawTerminalAX() { let reason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: true, suggestInIntegratedTerminals: true, @@ -100,10 +118,13 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { focusSnapshot: makeIntegratedTerminalSnapshot() ) - XCTAssertNil(reason, "Opting in should let the integrated terminal suggest like any field") + XCTAssertEqual( + reason, + "Terminal autocomplete is waiting for a live shell or Claude Code source." + ) } - func test_shouldSchedulePrediction_integratedTerminal_falseByDefault_trueWhenOptedIn() { + func test_shouldSchedulePrediction_requiresOptInAndAuthoritativeTerminalSource() { let snapshot = makeIntegratedTerminalSnapshot() XCTAssertFalse( @@ -113,14 +134,36 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { focusSnapshot: snapshot ) ) - XCTAssertTrue( + XCTAssertFalse( SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: true, suggestInIntegratedTerminals: true, + terminalIntegrationActive: true, inputMonitoringGranted: true, focusSnapshot: snapshot ) ) + XCTAssertTrue( + SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + suggestInIntegratedTerminals: true, + terminalIntegrationActive: true, + inputMonitoringGranted: true, + focusSnapshot: makeAuthoritativeTerminalSnapshot() + ) + ) + } + + func test_terminalSourceDoesNotEnterGenericVisualContextPipeline() { + XCTAssertFalse( + SuggestionAvailabilityEvaluator.shouldCaptureVisualContext( + suggestInIntegratedTerminals: true, + terminalIntegrationActive: true, + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeAuthoritativeTerminalSnapshot() + ) + ) } // MARK: - disabledReason: exact-string contracts diff --git a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift index 61b02751..cb5af92f 100644 --- a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift @@ -100,6 +100,97 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { } } + func test_terminalAcceptanceUsesPasteAndPublishesOptimisticEcho() { + runOnMainActor { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot( + applicationName: "Terminal", + bundleIdentifier: "com.apple.Terminal", + elementIdentifier: "terminal-shell-42-session", + role: TerminalInputRole.shell.rawValue, + subrole: ShellType.zsh.rawValue, + precedingText: "git st" + ) + let context = FocusedInputContext(snapshot: snapshot, generation: 7) + let interactionState = SuggestionInteractionState() + let session = interactionState.startSession( + fullText: "atus --short", + liveContext: context, + latency: 0.1 + ) + let inserter = StubSuggestionInserter() + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .visible( + text: session.remainingText, + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect), + mode: .inline + ), + inputMonitor: StubSuggestionInputMonitor(), + inserter: inserter, + interactionState: interactionState, + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + suggestInIntegratedTerminals: true + ) + ) + coordinator.terminalIntegrationActiveProvider = { true } + var echoedText: String? + coordinator.onTerminalInsertion = { _, text in echoedText = text } + + XCTAssertTrue(coordinator.acceptCurrentSuggestion()) + XCTAssertEqual(inserter.insertedChunks, ["atus"]) + XCTAssertEqual(inserter.insertionModes, [.terminalPaste]) + XCTAssertEqual(echoedText, "atus") + } + } + + func test_terminalCommandReplacementReplacesWholeLineWithoutSubmitting() { + runOnMainActor { + let original = "delete folder named dork" + let snapshot = CotabbyTestFixtures.focusedInputSnapshot( + applicationName: "Terminal", + bundleIdentifier: "com.apple.Terminal", + elementIdentifier: "terminal-shell-42-session", + role: TerminalInputRole.shell.rawValue, + subrole: ShellType.zsh.rawValue, + precedingText: original + ) + let context = FocusedInputContext(snapshot: snapshot, generation: 7) + let interactionState = SuggestionInteractionState() + let session = interactionState.startSession( + fullText: "rm -rf -- dork", + liveContext: context, + latency: 0.1, + kind: .terminalCommandReplacement(originalText: original) + ) + let inserter = StubSuggestionInserter() + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .visible( + text: session.remainingText, + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect), + mode: .inline + ), + inputMonitor: StubSuggestionInputMonitor(), + inserter: inserter, + interactionState: interactionState, + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + suggestInIntegratedTerminals: true + ) + ) + coordinator.terminalIntegrationActiveProvider = { true } + var optimisticReplacement: String? + coordinator.onTerminalReplacement = { _, text in optimisticReplacement = text } + + XCTAssertTrue(coordinator.acceptCurrentSuggestion()) + XCTAssertEqual( + inserter.terminalLineReplacements, + [.init(deleteCount: original.count, text: "rm -rf -- dork")] + ) + XCTAssertEqual(optimisticReplacement, "rm -rf -- dork") + XCTAssertTrue(inserter.insertedChunks.isEmpty, "Acceptance must not synthesize Return.") + } + } + func test_acceptCurrentSuggestion_withAddSpaceAfterAccept_insertsTrailingSpaceOnNonFinalWord() { // Full coordinator path with the setting ON and a multi-word suggestion: accepting the first // word must insert the word plus the suggestion's own following space (so the toggle fires @@ -598,13 +689,25 @@ private final class StubSuggestionOverlayController: SuggestionOverlayControllin @MainActor private final class StubSuggestionInserter: SuggestionInserting { + struct TerminalLineReplacement: Equatable { + let deleteCount: Int + let text: String + } + var lastErrorMessage: String? var insertedChunks: [String] = [] + var insertionModes: [SuggestionInsertionMode] = [] var replacements: [(deleteCount: Int, text: String)] = [] + var terminalLineReplacements: [TerminalLineReplacement] = [] var shouldInsert = true func insert(_ suggestion: String) -> Bool { + insert(suggestion, mode: .automatic) + } + + func insert(_ suggestion: String, mode: SuggestionInsertionMode) -> Bool { insertedChunks.append(suggestion) + insertionModes.append(mode) return shouldInsert } @@ -612,6 +715,11 @@ private final class StubSuggestionInserter: SuggestionInserting { replacements.append((deletingUTF16Count, text)) return shouldInsert } + + func replaceTerminalLine(deletingCharacterCount: Int, with text: String) -> Bool { + terminalLineReplacements.append(.init(deleteCount: deletingCharacterCount, text: text)) + return shouldInsert + } } private enum StubSuggestionEngineError: Error { diff --git a/CotabbyTests/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/SuggestionCoordinatorTestSupport.swift index a3ff9016..1be83c83 100644 --- a/CotabbyTests/SuggestionCoordinatorTestSupport.swift +++ b/CotabbyTests/SuggestionCoordinatorTestSupport.swift @@ -99,11 +99,17 @@ final class RigOverlayController: SuggestionOverlayControlling { final class RigInserter: SuggestionInserting { var lastErrorMessage: String? var insertedChunks: [String] = [] + var insertionModes: [SuggestionInsertionMode] = [] var replacements: [(deleteCount: Int, text: String)] = [] var shouldInsert = true func insert(_ suggestion: String) -> Bool { + insert(suggestion, mode: .automatic) + } + + func insert(_ suggestion: String, mode: SuggestionInsertionMode) -> Bool { insertedChunks.append(suggestion) + insertionModes.append(mode) return shouldInsert } diff --git a/CotabbyTests/SuggestionStateHelperTests.swift b/CotabbyTests/SuggestionStateHelperTests.swift index cf35f659..06057bd3 100644 --- a/CotabbyTests/SuggestionStateHelperTests.swift +++ b/CotabbyTests/SuggestionStateHelperTests.swift @@ -69,6 +69,30 @@ final class ContextBufferTests: XCTestCase { } } + func test_materialize_terminalSessionIdentityParticipatesInGeneration() { + runOnMainActor { + let buffer = makeBuffer() + let first = buffer.materialize( + from: CotabbyTestFixtures.focusedInputSnapshot( + processIdentifier: 123, + elementIdentifier: "terminal-shell-100-a", + role: TerminalInputRole.shell.rawValue, + precedingText: "git st" + ) + ) + let second = buffer.materialize( + from: CotabbyTestFixtures.focusedInputSnapshot( + processIdentifier: 123, + elementIdentifier: "terminal-shell-101-b", + role: TerminalInputRole.shell.rawValue, + precedingText: "git st" + ) + ) + + XCTAssertEqual(second.generation, first.generation + 1) + } + } + func test_clearDropsCurrentContextAndAdvancesFutureGeneration() { runOnMainActor { let buffer = makeBuffer() diff --git a/CotabbyTests/SyntheticReplacePlannerTests.swift b/CotabbyTests/SyntheticReplacePlannerTests.swift index 60a47ef1..fcaeead5 100644 --- a/CotabbyTests/SyntheticReplacePlannerTests.swift +++ b/CotabbyTests/SyntheticReplacePlannerTests.swift @@ -46,4 +46,35 @@ final class SyntheticReplacePlannerTests: XCTestCase { XCTAssertEqual(plan.insertUTF16, Array("ab".utf16)) } + + func test_terminalLinePlanDeletesEntireInstructionBeforePastingCommand() throws { + let original = "go to documents folder" + let plan = try XCTUnwrap(TerminalLineReplacementPlanner.plan( + deletingCharacterCount: original.count, + text: "cd ~/Documents" + )) + + XCTAssertEqual(plan.backspaceCount, original.count) + XCTAssertEqual(plan.replacementText, "cd ~/Documents") + XCTAssertEqual( + plan.operations, + Array(repeating: .backspace, count: original.count) + [.paste], + "Paste must be the final operation so deletes cannot erase the translated command" + ) + } + + func test_terminalLinePlanNeverAddsACommandSubmissionOperation() throws { + let plan = try XCTUnwrap(TerminalLineReplacementPlanner.plan( + deletingCharacterCount: 4, + text: "pwd" + )) + + XCTAssertEqual(plan.operations.filter { $0 == .paste }.count, 1) + XCTAssertEqual(plan.operations.last, .paste) + } + + func test_terminalLinePlanRejectsInvalidInputs() { + XCTAssertNil(TerminalLineReplacementPlanner.plan(deletingCharacterCount: -1, text: "pwd")) + XCTAssertNil(TerminalLineReplacementPlanner.plan(deletingCharacterCount: 3, text: "")) + } } diff --git a/CotabbyTests/TerminalAppDetectorTests.swift b/CotabbyTests/TerminalAppDetectorTests.swift index 5a02311c..cb2990de 100644 --- a/CotabbyTests/TerminalAppDetectorTests.swift +++ b/CotabbyTests/TerminalAppDetectorTests.swift @@ -98,7 +98,7 @@ final class TerminalAppDetectorTests: XCTestCase { focusSnapshot: snapshot ) - XCTAssertEqual(reason, "Cotabby is not available in terminal apps.") + XCTAssertEqual(reason, "Terminal autocomplete is turned off.") } func test_evaluator_doesNotBlockNonTerminalApp() { diff --git a/CotabbyTests/TerminalIntegrationModelTests.swift b/CotabbyTests/TerminalIntegrationModelTests.swift new file mode 100644 index 00000000..0f625af0 --- /dev/null +++ b/CotabbyTests/TerminalIntegrationModelTests.swift @@ -0,0 +1,305 @@ +import CoreGraphics +import Darwin +import XCTest +@testable import Cotabby + +/// Pure-contract tests for terminal state, prompting, geometry, and Claude Code classification. +/// Socket lifecycle and ScreenCaptureKit remain service integration concerns; these tests lock the +/// deterministic rules that protect Unicode offsets, source identity, and fail-closed OCR. +@MainActor +final class TerminalIntegrationModelTests: XCTestCase { + func test_bashCursorOffsetNormalizesUTF8BytesToCharacters() { + XCTAssertEqual( + TerminalFocusSnapshot.normalizedCharacterOffset( + rawOffset: "echo 🐱".utf8.count, + text: "echo 🐱", + shell: .bash + ), + "echo 🐱".count + ) + XCTAssertEqual( + TerminalFocusSnapshot.normalizedCharacterOffset( + rawOffset: 6, + text: "écho", + shell: .bash + ), + 4 + ) + } + + func test_optimisticInsertionPreservesTrailingBufferAndAdvancesRevision() { + let original = shellSnapshot(text: "git st --short", cursor: 6, revision: 7) + + let updated = original.appendingInsertedText("atus") + + XCTAssertEqual(updated.commandBuffer, "git status --short") + XCTAssertEqual(updated.cursorCharacterOffset, 10) + XCTAssertEqual(updated.sourceRevision, 8) + } + + func test_optimisticReplacementReplacesWholeBufferWithoutExecuting() { + let original = shellSnapshot(text: "delete folder named dork", cursor: 24, revision: 7) + + let updated = original.replacingCommandBuffer(with: "rm -rf -- dork") + + XCTAssertEqual(updated.commandBuffer, "rm -rf -- dork") + XCTAssertEqual(updated.cursorCharacterOffset, 14) + XCTAssertEqual(updated.sourceRevision, 8) + } + + func test_commandIntentPolicyRecognizesEnglishButNotShellSyntax() { + XCTAssertTrue(TerminalCommandIntentPolicy.isReplacementIntent("delete folder named dork")) + XCTAssertTrue(TerminalCommandIntentPolicy.isReplacementIntent("list hidden files")) + XCTAssertFalse(TerminalCommandIntentPolicy.isReplacementIntent("git status")) + XCTAssertFalse(TerminalCommandIntentPolicy.isReplacementIntent("rm -rf -- dork")) + XCTAssertFalse(TerminalCommandIntentPolicy.isReplacementIntent("list files | sort")) + } + + func test_requestFactoryBuildsWholeCommandReplacementAndNormalizerStripsFence() { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot( + applicationName: "Terminal", + bundleIdentifier: "com.apple.Terminal", + elementIdentifier: "terminal-shell-42-session", + role: TerminalInputRole.shell.rawValue, + subrole: ShellType.zsh.rawValue, + precedingText: "delete folder named dork" + ) + let context = FocusedInputContext(snapshot: snapshot, generation: 7) + + let build = SuggestionRequestFactory.buildRequest( + context: context, + settings: CotabbyTestFixtures.settingsSnapshot(suggestInIntegratedTerminals: true), + configuration: .standard + ) + + XCTAssertEqual( + build.request.mode, + .terminalCommandReplacement(originalText: "delete folder named dork") + ) + XCTAssertGreaterThanOrEqual(build.request.maxPredictionTokens, 32) + XCTAssertTrue(build.request.prompt.hasSuffix("Instruction: delete folder named dork\nCommand:")) + XCTAssertEqual( + SuggestionTextNormalizer.normalize("```sh\nrm -rf -- dork\n```", for: build.request), + "rm -rf -- dork" + ) + } + + func test_productPathsKeepDevelopmentSocketSeparate() { + let root = URL(fileURLWithPath: "/tmp/cotabby-tests", isDirectory: true) + let production = TerminalIntegrationPaths( + bundleIdentifier: "com.jacobfu.tabby", + applicationSupportRoot: root, + socketRoot: root + ) + let development = TerminalIntegrationPaths( + bundleIdentifier: "com.jacobfu.tabby.dev", + applicationSupportRoot: root, + socketRoot: root + ) + + XCTAssertNotEqual(production.socketURL, development.socketURL) + XCTAssertTrue(development.socketURL.lastPathComponent.contains("dev")) + XCTAssertLessThan(development.socketURL.path.utf8.count + 1, 104) + } + + func test_resourceInstallerCopiesHooksWithPrivatePermissions() throws { + let temporaryRoot = FileManager.default.temporaryDirectory.appendingPathComponent( + "cotabby-terminal-installer-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let bundled = temporaryRoot.appendingPathComponent("bundled", isDirectory: true) + try FileManager.default.createDirectory(at: bundled, withIntermediateDirectories: true) + for shell in [ShellType.zsh, .bash, .fish] { + try Data("hook-\(shell.rawValue)".utf8).write( + to: bundled.appendingPathComponent("cotabby.\(shell.rawValue)") + ) + } + let paths = TerminalIntegrationPaths( + bundleIdentifier: "com.example.cotabby.tests", + applicationSupportRoot: temporaryRoot, + socketRoot: temporaryRoot + ) + let installer = TerminalIntegrationResourceInstaller( + paths: paths, + bundledHooksDirectory: bundled + ) + + try installer.install() + + var rootInfo = stat() + XCTAssertEqual(lstat(paths.rootDirectory.path, &rootInfo), 0) + XCTAssertEqual(rootInfo.st_mode & 0o777, 0o700) + for shell in [ShellType.zsh, .bash, .fish] { + let destination = paths.hookURL(for: shell) + XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "hook-\(shell.rawValue)") + var info = stat() + XCTAssertEqual(lstat(destination.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + } + } + + func test_socketServerCreatesPrivateEndpointAndRemovesItOnStop() throws { + let temporaryRoot = FileManager.default.temporaryDirectory.appendingPathComponent( + "ct-\(UUID().uuidString.prefix(8))", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let socketURL = temporaryRoot.appendingPathComponent("terminal.sock") + let server = TerminalSocketServer(socketURL: socketURL) { _ in } + + try server.start() + + var rootInfo = stat() + var socketInfo = stat() + XCTAssertEqual(lstat(temporaryRoot.path, &rootInfo), 0) + XCTAssertEqual(rootInfo.st_mode & 0o777, 0o700) + XCTAssertEqual(lstat(socketURL.path, &socketInfo), 0) + XCTAssertEqual(socketInfo.st_mode & 0o777, 0o600) + XCTAssertEqual(socketInfo.st_mode & S_IFMT, S_IFSOCK) + + server.stop() + XCTAssertNotEqual(lstat(socketURL.path, &socketInfo), 0) + XCTAssertEqual(errno, ENOENT) + } + + func test_shellPromptPreservesExactPrefixAsFinalBytes() { + let prompt = TerminalCompletionPromptRenderer.prompt( + prefixText: "git commit -m 'fix spacing ", + role: .shell, + shellName: "zsh", + workingDirectory: "/Users/test/project" + ) + + XCTAssertTrue(prompt.contains("Working directory: Users/test/project")) + XCTAssertTrue(prompt.hasSuffix("$ git commit -m 'fix spacing ")) + } + + func test_promptAnchorMatchesBottomMostCommandAndTracksCursor() throws { + let snapshot = shellSnapshot(text: "git st", cursor: 6) + let lines = [ + RecognizedTextLine( + text: "$ git st", + confidence: 0.9, + boundingBox: CGRect(x: 0.1, y: 0.7, width: 0.08, height: 0.03) + ), + RecognizedTextLine( + text: "$ git st", + confidence: 0.95, + boundingBox: CGRect(x: 0.1, y: 0.1, width: 0.08, height: 0.03) + ) + ] + let region = CGRect(x: 0, y: 0, width: 800, height: 600) + let anchor = try XCTUnwrap( + TerminalPromptAnchorResolver.makeAnchor( + snapshot: snapshot, + lines: lines, + captureRegion: region, + windowFrame: region + ) + ) + + XCTAssertEqual(anchor.promptLineRect.minY, 522, accuracy: 0.001) + let geometry = TerminalPromptAnchorResolver.geometry(cursorOffset: 6, anchor: anchor) + XCTAssertGreaterThan(geometry.caret.minX, anchor.promptLineRect.minX) + XCTAssertEqual(geometry.input.width, region.width) + } + + func test_tuiDetectorUsesTitleThenProcessAndRejectsNonTerminalApps() { + XCTAssertEqual( + TuiSessionDetector.classification( + bundleIdentifier: "com.apple.Terminal", + terminalAccessibilityTitle: "project — Claude Code", + foregroundProcessNames: { XCTFail("Title should short-circuit process lookup"); return [] } + ), + .claudeCode + ) + XCTAssertEqual( + TuiSessionDetector.classification( + bundleIdentifier: "com.mitchellh.ghostty", + terminalAccessibilityTitle: "project", + foregroundProcessNames: { ["zsh", "claude"] } + ), + .claudeCode + ) + XCTAssertEqual( + TuiSessionDetector.classification( + bundleIdentifier: "com.apple.Safari", + terminalAccessibilityTitle: "Claude Code", + foregroundProcessNames: { ["claude"] } + ), + .notClaudeCode + ) + } + + func test_tuiDetectorFallsBackToOCRWhenTerminalProcessTreeIsInconclusive() { + XCTAssertEqual( + TuiSessionDetector.classification( + bundleIdentifier: "com.apple.Terminal", + terminalAccessibilityTitle: "project", + foregroundProcessNames: { ["Terminal", "login", "zsh"] } + ), + .unknown + ) + } + + func test_tuiReaderRequiresWindowFingerprintAndSelectsBottomMostPrompt() async throws { + let extraction = ExtractedScreenText( + text: "Claude Code\n❯ menu item\n❯ explain this test\nshift+tab", + lineCount: 4, + positionedLines: [ + .init(text: "Claude Code", confidence: 1, boundingBox: .init(x: 0, y: 0.8, width: 0.2, height: 0.05)), + .init(text: "❯ menu item", confidence: 1, boundingBox: .init(x: 0, y: 0.5, width: 0.2, height: 0.05)), + .init(text: "❯ explain this test", confidence: 1, boundingBox: .init(x: 0, y: 0.2, width: 0.3, height: 0.05)), + .init(text: "shift+tab", confidence: 1, boundingBox: .init(x: 0, y: 0.1, width: 0.2, height: 0.05)) + ] + ) + let reader = TuiContextReader(extractor: StubScreenTextExtractor(result: extraction)) + + let reading = try await reader.read(image: makeImage()) + + XCTAssertTrue(reading.looksLikeClaudeCode) + XCTAssertEqual(reading.promptText, "explain this test") + XCTAssertEqual(reading.estimatedCursorOffset, 17) + } + + private func shellSnapshot( + text: String, + cursor: Int, + revision: UInt64 = 1 + ) -> TerminalFocusSnapshot { + TerminalFocusSnapshot( + sessionIdentity: TerminalSessionIdentity(shellPid: 42, nonce: "session"), + commandBuffer: text, + cursorCharacterOffset: cursor, + shellType: .zsh, + terminalBundleIdentifier: "com.apple.Terminal", + tty: "/dev/ttys001", + workingDirectory: "/Users/test/project", + sourceRevision: revision + ) + } + + private func makeImage() throws -> CGImage { + let context = try XCTUnwrap( + CGContext( + data: nil, + width: 4, + height: 4, + bitsPerComponent: 8, + bytesPerRow: 16, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + ) + return try XCTUnwrap(context.makeImage()) + } +} + +private struct StubScreenTextExtractor: ScreenTextExtracting { + let result: ExtractedScreenText + + func extractText(from image: CGImage) async throws -> ExtractedScreenText { + result + } +} diff --git a/README.md b/README.md index c614b4f2..7fbfd1d4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Everything runs on your Mac. No account, no cloud, no telemetry. - **Emoji autocomplete** — type `:rocket:` and accept it without leaving the field - **Inline macros** — type `/` for quick math, unit and currency conversion, dates, and random values - **One-key autocorrect** — fix a likely typo with a single keystroke +- **Terminal autocomplete (beta)** — opt-in ghost text for zsh/bash/fish prompts and Claude Code ## Privacy @@ -140,6 +141,10 @@ Start typing in almost any text field. When a gray suggestion appears: Every shortcut is rebindable under Settings → Shortcuts. +Terminal autocomplete is opt-in under Settings → Apps. Cotabby shows the exact shell setup command +for the running production or development build; see [TERMINAL_AUTOCOMPLETE.md](TERMINAL_AUTOCOMPLETE.md) +for supported shells, privacy boundaries, and limitations. + ## Permissions Cotabby works inside other apps, so macOS asks for a few permissions. Each one maps to a specific feature, and Cotabby walks you through them on first launch: diff --git a/TERMINAL_AUTOCOMPLETE.md b/TERMINAL_AUTOCOMPLETE.md new file mode 100644 index 00000000..d758187a --- /dev/null +++ b/TERMINAL_AUTOCOMPLETE.md @@ -0,0 +1,70 @@ +# Terminal Autocomplete (Beta) + +Cotabby can provide inline autocomplete in dedicated terminal apps and integrated terminal panes. +It is off by default because terminal output is not a normal editable text field and must never be +treated as one without a trustworthy input source. + +## Enable and set up + +1. Open **Settings → Apps** and enable **Terminal Autocomplete (Beta)**. +2. Grant Screen Recording if prompted. Cotabby uses it to locate the visible shell prompt and to + read Claude Code's input box; screenshots and recognized text stay on the Mac. +3. Copy the zsh, Bash, or fish command shown in Settings into that shell's startup file. +4. Open a new terminal window. + +Settings generates paths for the exact app build being used. This matters during development: +Cotabby and Cotabby Dev use different sockets and installed hook directories, so they can run +side-by-side without unlinking or impersonating each other's endpoint. + +zsh is recommended. It uses `line-pre-redraw` and does not replace normal key bindings. Bash 4+ +and fish do not expose an equivalent generic redraw hook, so their beta hooks wrap printable-key +bindings and may conflict with custom Readline/fish bindings. The stock Bash included with macOS +only provides prompt heartbeats; install Bash 4 or newer for live per-keystroke suggestions. + +## How it works + +Shell prompts and terminal TUIs use separate source adapters: + +- **zsh, Bash, and fish:** a sourced hook sends the exact line-editor buffer, cursor, shell, TTY, + working directory, session nonce, and monotonic revision over a local Unix-domain socket. OCR + matches that exact buffer to the visible prompt once, then cursor movement is tracked from the + hook's character offset until the anchor expires or the prompt moves. +- **Claude Code:** the shell no longer owns stdin, so Cotabby checks the frontmost terminal title + and process tree, captures only the active terminal window or integrated pane, and runs Apple's + Vision OCR locally. A Claude Code screen fingerprint and a prompt line must both be present before + the OCR text becomes autocomplete focus. + +Both sources feed the existing suggestion coordinator. The normal accept shortcuts still apply; +there is no separate terminal-only shortcut. Accepted terminal text is inserted through paste so +bracketed-paste-aware shells and TUIs receive literal text. + +At an end-of-line shell prompt, a short imperative English instruction such as +`delete folder named dork` enters command-replacement mode. Cotabby shows the complete translated +command as a replacement offer; accepting deletes the unchanged English line and pastes the command. +It never presses Return, so commands—including destructive ones—remain visible for review and only +run after the user explicitly submits them. Real shell syntax, paths, flags, pipes, and redirects +remain on the normal continuation path. + +## Security and privacy boundaries + +- The socket lives under the current user's private temporary directory so its endpoint stays below + macOS's Unix-socket path limit. Its directory is mode `0700`, the socket is mode `0600`, and peer + user IDs are checked before any frame is decoded. Installed hook copies remain in Application + Support. +- Existing non-socket paths, sockets owned by another user, and live endpoints owned by another + Cotabby process are never removed. Only a same-user, non-listening stale socket is replaced. +- Frames are newline-delimited JSON capped at 64 KiB. Shell PIDs must belong to the current user; + bundle IDs, TTY paths, nonces, revisions, cursor offsets, and text sizes are validated. +- The IPC/OCR subsystem does not write terminal data to disk or make network requests. Like normal + autocomplete text, the result flows to the engine selected in Cotabby; choosing a remote + OpenAI-compatible endpoint therefore carries the same privacy tradeoff as it does in other apps. +- Enabling the preference is only a master switch. Cotabby still refuses terminal autocomplete + unless a live hook snapshot or a currently verified Claude Code OCR snapshot owns focus. + +## Current limitations + +- Screen Recording is required for trustworthy prompt positioning and for Claude Code OCR. +- Claude Code OCR is best-effort and intentionally fail-closed. If the UI fingerprint, prompt line, + frontmost app, or window geometry changes during OCR, Cotabby hides the terminal suggestion. +- Multiplexer panes and heavily customized prompt themes can make OCR anchoring less reliable. +- Bash/fish custom key bindings may need manual adjustment; zsh is the compatibility-first path. diff --git a/project.yml b/project.yml index 8c6fcee2..9cd26ddc 100644 --- a/project.yml +++ b/project.yml @@ -104,6 +104,11 @@ targetTemplates: deploymentTarget: "14.0" sources: - path: Cotabby + - path: scripts/shell-integration + buildPhase: + copyFiles: + destination: resources + subpath: shell-integration dependencies: - package: Sparkle product: Sparkle diff --git a/scripts/shell-integration/cotabby.bash b/scripts/shell-integration/cotabby.bash new file mode 100644 index 00000000..33b41871 --- /dev/null +++ b/scripts/shell-integration/cotabby.bash @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Cotabby cooperative prompt integration for bash. +# Live per-keystroke reporting requires Bash 4+ (`brew install bash` on macOS). + +[[ $- == *i* ]] || return 0 +[[ -n "${_cotabby_integration_loaded:-}" ]] && return 0 +_cotabby_integration_loaded=1 + +_cotabby_socket_root="${TMPDIR:-/tmp}" +_cotabby_socket="${COTABBY_SOCKET_PATH:-${_cotabby_socket_root%/}/cotabby-terminal/cotabby.sock}" +_cotabby_nc=/usr/bin/nc +[[ -x "$_cotabby_nc" ]] || { printf '%s\n' '[cotabby] /usr/bin/nc unavailable; integration disabled' >&2; return 0; } + +if [[ -n "${__CFBundleIdentifier:-}" ]]; then + _cotabby_terminal_bundle_id="$__CFBundleIdentifier" +elif [[ "${TERM_PROGRAM:-}" == ghostty ]]; then _cotabby_terminal_bundle_id=com.mitchellh.ghostty +elif [[ "${TERM_PROGRAM:-}" == iTerm.app ]]; then _cotabby_terminal_bundle_id=com.googlecode.iterm2 +elif [[ "${TERM_PROGRAM:-}" == Apple_Terminal ]]; then _cotabby_terminal_bundle_id=com.apple.Terminal +elif [[ "${TERM_PROGRAM:-}" == vscode ]]; then _cotabby_terminal_bundle_id=com.microsoft.VSCode +elif [[ "${TERM_PROGRAM:-}" == WezTerm ]]; then _cotabby_terminal_bundle_id=com.github.wez.wezterm +elif [[ "${TERM_PROGRAM:-}" == kitty ]]; then _cotabby_terminal_bundle_id=net.kovidgoyal.kitty +elif [[ "${TERM_PROGRAM:-}" == Alacritty ]]; then _cotabby_terminal_bundle_id=io.alacritty +elif [[ "${TERM_PROGRAM:-}" == Hyper ]]; then _cotabby_terminal_bundle_id=co.zeit.hyper +elif [[ "${TERM_PROGRAM:-}" == WarpTerminal ]]; then _cotabby_terminal_bundle_id=dev.warp.Warp-Stable +elif [[ "${TERM_PROGRAM:-}" == Rio ]]; then _cotabby_terminal_bundle_id=io.rio.terminal +else _cotabby_terminal_bundle_id=unknown +fi + +_cotabby_session="$$-${RANDOM}-${SECONDS}" +_cotabby_tty="$(tty 2>/dev/null)" +[[ "$_cotabby_tty" == /dev/* ]] || { printf '%s\n' '[cotabby] no interactive tty; integration disabled' >&2; return 0; } +_cotabby_revision=0 +_cotabby_last_state='' + +_cotabby_escape_json() { + REPLY="$1" + REPLY="${REPLY//\\/\\\\}" + REPLY="${REPLY//\"/\\\"}" + REPLY="${REPLY//$'\n'/\\n}" + REPLY="${REPLY//$'\t'/\\t}" + REPLY="${REPLY//$'\r'/\\r}" +} + +_cotabby_send() { + [[ -S "$_cotabby_socket" ]] || return 0 + printf '%s\n' "$1" | "$_cotabby_nc" -U -w 1 "$_cotabby_socket" 2>/dev/null +} + +_cotabby_report_buffer() { + local line="${READLINE_LINE-}" point="${READLINE_POINT:-0}" + local state="${line}"$'\x1f'"${point}"$'\x1f'"${PWD}" + [[ "$state" == "$_cotabby_last_state" ]] && return 0 + _cotabby_last_state="$state" + ((_cotabby_revision++)) + _cotabby_escape_json "$line"; local text="$REPLY" + _cotabby_escape_json "$PWD"; local cwd="$REPLY" + _cotabby_escape_json "$_cotabby_tty"; local tty="$REPLY" + _cotabby_send "{\"type\":\"buffer\",\"text\":\"${text}\",\"cursor\":${point},\"shell\":\"bash\",\"terminal\":\"${_cotabby_terminal_bundle_id}\",\"pid\":$$,\"session\":\"${_cotabby_session}\",\"tty\":\"${tty}\",\"cwd\":\"${cwd}\",\"revision\":${_cotabby_revision}}" +} + +_cotabby_report_disconnect() { + _cotabby_send "{\"type\":\"disconnect\",\"pid\":$$,\"session\":\"${_cotabby_session}\"}" +} +trap _cotabby_report_disconnect EXIT + +if [[ -n "${PROMPT_COMMAND:-}" ]]; then + PROMPT_COMMAND="_cotabby_report_buffer;${PROMPT_COMMAND}" +else + PROMPT_COMMAND=_cotabby_report_buffer +fi + +if (( BASH_VERSINFO[0] < 4 )); then + printf '%s\n' "[cotabby] bash ${BASH_VERSION} supports prompt heartbeats only; Bash 4+ is required for live typing" >&2 + return 0 +fi + +_cotabby_self_insert() { + local value="$1" + READLINE_LINE="${READLINE_LINE:0:READLINE_POINT}${value}${READLINE_LINE:READLINE_POINT}" + ((READLINE_POINT += ${#value})) + _cotabby_report_buffer +} + +_cotabby_bind_printables() { + local code value key + for ((code=32; code<=126; code++)); do + printf -v value "\\$(printf '%03o' "$code")" + printf -v "_cotabby_char_${code}" '%s' "$value" + key="$value" + [[ "$value" == '"' ]] && key='\"' + [[ "$value" == '\' ]] && key='\\' + bind -x "\"${key}\":_cotabby_self_insert \"\$_cotabby_char_${code}\"" 2>/dev/null + done +} +_cotabby_bind_printables +unset -f _cotabby_bind_printables + +_cotabby_backspace() { + if ((READLINE_POINT > 0)); then + READLINE_LINE="${READLINE_LINE:0:READLINE_POINT-1}${READLINE_LINE:READLINE_POINT}" + ((READLINE_POINT--)) + fi + _cotabby_report_buffer +} +bind -x '"\C-h":_cotabby_backspace' 2>/dev/null +bind -x '"\C-?":_cotabby_backspace' 2>/dev/null + +printf '%s\n' "[cotabby] shell integration loaded for ${_cotabby_terminal_bundle_id}" >&2 diff --git a/scripts/shell-integration/cotabby.fish b/scripts/shell-integration/cotabby.fish new file mode 100644 index 00000000..da9bf2ec --- /dev/null +++ b/scripts/shell-integration/cotabby.fish @@ -0,0 +1,93 @@ +# Cotabby cooperative prompt integration for fish. +# Source from ~/.config/fish/conf.d/cotabby.fish after enabling the feature. + +status is-interactive; or return 0 +set -q _cotabby_integration_loaded; and return 0 +set -g _cotabby_integration_loaded 1 + +set -g _cotabby_socket "$COTABBY_SOCKET_PATH" +set -l _cotabby_socket_root "$TMPDIR" +test -n "$_cotabby_socket_root"; or set _cotabby_socket_root /tmp +test -n "$_cotabby_socket"; or set -g _cotabby_socket (string trim -r -c / -- $_cotabby_socket_root)/cotabby-terminal/cotabby.sock +set -g _cotabby_nc /usr/bin/nc +test -x $_cotabby_nc; or begin; echo '[cotabby] /usr/bin/nc unavailable; integration disabled' >&2; return 0; end + +if set -q __CFBundleIdentifier + set -g _cotabby_terminal_bundle_id "$__CFBundleIdentifier" +else if test "$TERM_PROGRAM" = ghostty + set -g _cotabby_terminal_bundle_id com.mitchellh.ghostty +else if test "$TERM_PROGRAM" = iTerm.app + set -g _cotabby_terminal_bundle_id com.googlecode.iterm2 +else if test "$TERM_PROGRAM" = Apple_Terminal + set -g _cotabby_terminal_bundle_id com.apple.Terminal +else if test "$TERM_PROGRAM" = vscode + set -g _cotabby_terminal_bundle_id com.microsoft.VSCode +else if test "$TERM_PROGRAM" = WezTerm + set -g _cotabby_terminal_bundle_id com.github.wez.wezterm +else if test "$TERM_PROGRAM" = kitty + set -g _cotabby_terminal_bundle_id net.kovidgoyal.kitty +else if test "$TERM_PROGRAM" = Alacritty + set -g _cotabby_terminal_bundle_id io.alacritty +else if test "$TERM_PROGRAM" = Hyper + set -g _cotabby_terminal_bundle_id co.zeit.hyper +else if test "$TERM_PROGRAM" = WarpTerminal + set -g _cotabby_terminal_bundle_id dev.warp.Warp-Stable +else if test "$TERM_PROGRAM" = Rio + set -g _cotabby_terminal_bundle_id io.rio.terminal +else + set -g _cotabby_terminal_bundle_id unknown +end + +set -g _cotabby_session "$fish_pid-"(random)"-"$CMD_DURATION +set -g _cotabby_tty (tty 2>/dev/null) +string match -q '/dev/*' -- $_cotabby_tty; or begin; echo '[cotabby] no interactive tty; integration disabled' >&2; return 0; end +set -g _cotabby_revision 0 +set -g _cotabby_last_state '' + +function _cotabby_escape_json + string escape --style=json -- $argv[1] | string replace -r '^"|"$' '' +end + +function _cotabby_send + test -S $_cotabby_socket; or return 0 + printf '%s\n' $argv[1] | $_cotabby_nc -U -w 1 $_cotabby_socket 2>/dev/null +end + +function _cotabby_report_buffer + set -l text (commandline) + set -l cursor (commandline -C) + set -l state "$text\x1f$cursor\x1f$PWD" + test "$state" = "$_cotabby_last_state"; and return 0 + set -g _cotabby_last_state "$state" + set -g _cotabby_revision (math $_cotabby_revision + 1) + set -l escaped_text (_cotabby_escape_json "$text") + set -l escaped_tty (_cotabby_escape_json "$_cotabby_tty") + set -l escaped_cwd (_cotabby_escape_json "$PWD") + _cotabby_send "{\"type\":\"buffer\",\"text\":\"$escaped_text\",\"cursor\":$cursor,\"shell\":\"fish\",\"terminal\":\"$_cotabby_terminal_bundle_id\",\"pid\":$fish_pid,\"session\":\"$_cotabby_session\",\"tty\":\"$escaped_tty\",\"cwd\":\"$escaped_cwd\",\"revision\":$_cotabby_revision}" +end + +function _cotabby_report_disconnect --on-event fish_exit + _cotabby_send "{\"type\":\"disconnect\",\"pid\":$fish_pid,\"session\":\"$_cotabby_session\"}" +end + +# Fish has no generic post-self-insert hook. The opt-in integration wraps printable keys and the +# most common editing key; users relying heavily on custom bind/abbr behavior should prefer zsh. +for _cotabby_code in (seq 32 126) + set -l _cotabby_char (printf '%b' (printf '\\%03o' $_cotabby_code)) + set -l _cotabby_escaped (string escape -- $_cotabby_char) + bind --silent -- $_cotabby_char "commandline -i -- $_cotabby_escaped" _cotabby_report_buffer 2>/dev/null +end +set -e _cotabby_code + +function _cotabby_backspace + commandline -f backward-delete-char + _cotabby_report_buffer +end +bind --silent \b _cotabby_backspace 2>/dev/null +bind --silent \ch _cotabby_backspace 2>/dev/null + +function _cotabby_prompt_report --on-event fish_prompt + _cotabby_report_buffer +end + +echo "[cotabby] shell integration loaded for $_cotabby_terminal_bundle_id" >&2 diff --git a/scripts/shell-integration/cotabby.zsh b/scripts/shell-integration/cotabby.zsh new file mode 100644 index 00000000..46c32865 --- /dev/null +++ b/scripts/shell-integration/cotabby.zsh @@ -0,0 +1,92 @@ +#!/usr/bin/env zsh +# Cotabby cooperative prompt integration for zsh. +# Source this file from .zshrc after enabling Terminal Autocomplete in Cotabby. + +[[ -o interactive ]] || return 0 +[[ -n "${_cotabby_integration_loaded:-}" ]] && return 0 +_cotabby_integration_loaded=1 + +_cotabby_socket_root="${TMPDIR:-/tmp}" +_cotabby_socket="${COTABBY_SOCKET_PATH:-${_cotabby_socket_root%/}/cotabby-terminal/cotabby.sock}" +_cotabby_nc=/usr/bin/nc +[[ -x "$_cotabby_nc" ]] || { print -u2 "[cotabby] /usr/bin/nc is unavailable; integration disabled"; return 0; } + +if [[ -n "${__CFBundleIdentifier:-}" ]]; then + _cotabby_terminal_bundle_id="$__CFBundleIdentifier" +elif [[ "${TERM_PROGRAM:-}" == ghostty ]]; then + _cotabby_terminal_bundle_id=com.mitchellh.ghostty +elif [[ "${TERM_PROGRAM:-}" == iTerm.app ]]; then + _cotabby_terminal_bundle_id=com.googlecode.iterm2 +elif [[ "${TERM_PROGRAM:-}" == Apple_Terminal ]]; then + _cotabby_terminal_bundle_id=com.apple.Terminal +elif [[ "${TERM_PROGRAM:-}" == vscode ]]; then + _cotabby_terminal_bundle_id=com.microsoft.VSCode +elif [[ "${TERM_PROGRAM:-}" == WezTerm ]]; then + _cotabby_terminal_bundle_id=com.github.wez.wezterm +elif [[ "${TERM_PROGRAM:-}" == kitty ]]; then + _cotabby_terminal_bundle_id=net.kovidgoyal.kitty +elif [[ "${TERM_PROGRAM:-}" == Alacritty ]]; then + _cotabby_terminal_bundle_id=io.alacritty +elif [[ "${TERM_PROGRAM:-}" == Hyper ]]; then + _cotabby_terminal_bundle_id=co.zeit.hyper +elif [[ "${TERM_PROGRAM:-}" == WarpTerminal ]]; then + _cotabby_terminal_bundle_id=dev.warp.Warp-Stable +elif [[ "${TERM_PROGRAM:-}" == Rio ]]; then + _cotabby_terminal_bundle_id=io.rio.terminal +else + _cotabby_terminal_bundle_id=unknown +fi + +_cotabby_session="$$-${RANDOM}-${SECONDS}" +_cotabby_tty="$(tty 2>/dev/null)" +[[ "$_cotabby_tty" == /dev/* ]] || { print -u2 "[cotabby] no interactive tty; integration disabled"; return 0; } +typeset -gi _cotabby_revision=0 +typeset -g _cotabby_last_state="" + +_cotabby_escape_json() { + REPLY="$1" + REPLY="${REPLY//\\/\\\\}" + REPLY="${REPLY//\"/\\\"}" + REPLY="${REPLY//$'\n'/\\n}" + REPLY="${REPLY//$'\t'/\\t}" + REPLY="${REPLY//$'\r'/\\r}" +} + +_cotabby_send() { + [[ -S "$_cotabby_socket" ]] || return 0 + print -r -- "$1" | "$_cotabby_nc" -U -w 1 "$_cotabby_socket" 2>/dev/null +} + +_cotabby_report_buffer() { + local state="${BUFFER}\x1f${CURSOR}\x1f${PWD}" + [[ "$state" == "$_cotabby_last_state" ]] && return 0 + _cotabby_last_state="$state" + ((_cotabby_revision++)) + _cotabby_escape_json "$BUFFER"; local text="$REPLY" + _cotabby_escape_json "$PWD"; local cwd="$REPLY" + _cotabby_escape_json "$_cotabby_tty"; local tty="$REPLY" + _cotabby_send "{\"type\":\"buffer\",\"text\":\"${text}\",\"cursor\":${CURSOR:-0},\"shell\":\"zsh\",\"terminal\":\"${_cotabby_terminal_bundle_id}\",\"pid\":$$,\"session\":\"${_cotabby_session}\",\"tty\":\"${tty}\",\"cwd\":\"${cwd}\",\"revision\":${_cotabby_revision}}" +} + +_cotabby_report_disconnect() { + _cotabby_send "{\"type\":\"disconnect\",\"pid\":$$,\"session\":\"${_cotabby_session}\"}" +} + +# `line-pre-redraw` observes every editor change without replacing the user's ZLE widgets or +# keybindings. That compatibility boundary is why the hook requires the modern macOS zsh. +autoload -Uz add-zle-hook-widget 2>/dev/null +if (( $+functions[add-zle-hook-widget] )); then + add-zle-hook-widget line-pre-redraw _cotabby_report_buffer + add-zle-hook-widget line-init _cotabby_report_buffer +else + print -u2 "[cotabby] zsh line-pre-redraw hooks are unavailable; integration disabled" + return 0 +fi + +autoload -Uz add-zsh-hook 2>/dev/null +if (( $+functions[add-zsh-hook] )); then + add-zsh-hook precmd _cotabby_report_buffer + add-zsh-hook zshexit _cotabby_report_disconnect +fi + +print -u2 "[cotabby] shell integration loaded for ${_cotabby_terminal_bundle_id}"