From 1bafbdce5c40981d2ee4f250dfc0e81ce3d3a933 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 16:07:46 +0200 Subject: [PATCH] Add augmented translation for incoming messages and replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Translation (off by default) translates chats in apps the user picks (WhatsApp, Teams, Telegram, Slack, Outlook, Mail, Messages): - Incoming: every 2 s, and right after scrolling, TranslationCoordinator captures the frontmost chat window (new captureActiveWindow), reads it with Vision OCR using language hints, groups lines into messages (MessageBlockGrouper: conversation area above the reply field, timestamps and low-confidence lines dropped), and draws each translation under its message in a click-through overlay. Unchanged windows are skipped by a pixel fingerprint. - Replies: when the user writes in their reading language in a chat whose language is known, the reply is translated after a 700 ms pause and shown in a card under the field; ⌃⌥T (recordable) replaces the draft via select-all + paste with the clipboard restored. Never sends. - Engines: Apple Translation (TranslationSession(installedSource:target:), on-device) for its ~20 languages; for pairs it lacks, such as Macedonian, the selected Open Source model with a few-shot prompt, as the bundled models are base models. Results are cached in memory only. - TranslationLanguagePolicy is conservative (≥12 letters, ≥0.8 confidence, no links or numbers) and recognizes Macedonian by its alphabet, since Natural Language reports it as Bulgarian. Respects Cotabby's own gates (globally on, not paused, app not disabled, Low Power Mode), never reads secure fields, and stores nothing. Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 116 +++++++ .../Coordinators/SettingsCoordinator.swift | 10 +- .../Translation/TranslationCoordinator.swift | 304 ++++++++++++++++++ Cotabby/App/Core/AppDelegate.swift | 2 + Cotabby/App/Core/CotabbyAppEnvironment.swift | 36 ++- .../Translation/TranslationModels.swift | 96 ++++++ .../TranslationOverlayController.swift | 162 ++++++++++ .../Suggestion/SuggestionInserter.swift | 30 ++ .../Translation/TranslationEngines.swift | 149 +++++++++ .../Translation/TranslationHotkeyTap.swift | 84 +++++ .../TranslationPreferencesStore.swift | 88 +++++ .../Services/Visual/ScreenTextExtractor.swift | 11 +- .../Visual/WindowScreenshotService.swift | 41 +++ .../ConversationLanguageTracker.swift | 40 +++ .../FewShotTranslationPrompt.swift | 70 ++++ .../Translation/MessageBlockGrouper.swift | 99 ++++++ .../Translation/TranslationCache.swift | 49 +++ .../TranslationLanguagePolicy.swift | 96 ++++++ .../Settings/Panes/TranslationPaneView.swift | 211 ++++++++++++ Cotabby/UI/Settings/SettingsCategory.swift | 7 +- .../UI/Settings/SettingsContainerView.swift | 4 + Cotabby/UI/Settings/SettingsIndex.swift | 21 ++ .../Translation/TranslationCoreTests.swift | 214 ++++++++++++ 23 files changed, 1936 insertions(+), 4 deletions(-) create mode 100644 Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift create mode 100644 Cotabby/Models/Translation/TranslationModels.swift create mode 100644 Cotabby/Services/Presentation/TranslationOverlayController.swift create mode 100644 Cotabby/Services/Translation/TranslationEngines.swift create mode 100644 Cotabby/Services/Translation/TranslationHotkeyTap.swift create mode 100644 Cotabby/Services/Translation/TranslationPreferencesStore.swift create mode 100644 Cotabby/Support/Translation/ConversationLanguageTracker.swift create mode 100644 Cotabby/Support/Translation/FewShotTranslationPrompt.swift create mode 100644 Cotabby/Support/Translation/MessageBlockGrouper.swift create mode 100644 Cotabby/Support/Translation/TranslationCache.swift create mode 100644 Cotabby/Support/Translation/TranslationLanguagePolicy.swift create mode 100644 Cotabby/UI/Settings/Panes/TranslationPaneView.swift create mode 100644 CotabbyTests/Support/Translation/TranslationCoreTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..b5c2d9de 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 0209B7FDA016D12F91C1D197 /* BlockBreakAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */; }; 023A1A2A26B192E244192D8D /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E824A5614BC7E7FCA05AD29 /* TrailingDuplicationFilterTests.swift */; }; 02A837BD3861FD72B3E99D0F /* BaseCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612CA2988BF19FAE0114165A /* BaseCompletionPromptRendererTests.swift */; }; + 02BD1E581B8CB51A8912D93A /* TranslationLanguagePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */; }; 02C80969B8D6A0DBDC619FCA /* FocusSessionScopedCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487D8C5521411FC6845D4A51 /* FocusSessionScopedCacheTests.swift */; }; 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; 02DABCA8259C5C811D245239 /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B23950F38B1B3C8A5CC5BE5 /* OpenAICompatibleCredentialStore.swift */; }; @@ -70,7 +71,9 @@ 0E6692D2A7AEEDF72FCF1AE2 /* ExtendedContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0447A27B293394D9379C48 /* ExtendedContextTests.swift */; }; 1010A070B4E0F0A2FDF45112 /* CompletionContentPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA5F007F5185BD597550607 /* CompletionContentPolicyTests.swift */; }; 102E1EDC04636AFB607A8249 /* ActiveSuggestionSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F95814016D80E0A23974F0 /* ActiveSuggestionSession.swift */; }; + 1038CF642829E77824F2B25D /* TranslationLanguagePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */; }; 105187F185A579BC67CA2A93 /* AXHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5D1A7D938F633C6EE094F7 /* AXHelper.swift */; }; + 107DF2BD1B2616BD36E3C1EC /* TranslationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4F9870325E80C563192E40 /* TranslationCache.swift */; }; 1092E77D9D92DAC4808ABF9A /* GhostFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB26526298A85454330591 /* GhostFontResolver.swift */; }; 109F9BD7E1AE3E7FB0C712AA /* GhostTextLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EB202C5AD6079174A14330C /* GhostTextLayoutTests.swift */; }; 1104ABD01283774EDF249EF0 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; @@ -158,6 +161,7 @@ 24A641C63FB5DB47C2116A5C /* OpenAICompatibleEndpointConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40BA54CDA6F5887BEC6C668 /* OpenAICompatibleEndpointConfigurationTests.swift */; }; 24D5BE34BE7B7B72A93C6A9F /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AB5EBB44EE64EB3EB1426F /* BundledRuntimeLocator.swift */; }; 24E0F4F0EC9148FB409FD112 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; + 24EE74F6BE91FC7AF04AB8A7 /* TranslationOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */; }; 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; @@ -189,6 +193,7 @@ 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2E0AEA2199864F46F86FC0C0 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD33A18BECBD7292ACD9F8B8 /* TextDirectionDetector.swift */; }; 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; + 2E5F38501661C60DE7EFD980 /* FewShotTranslationPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */; }; 2EDFE6F33018D2D43D5CB813 /* MacroEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974A8708D2006767BD76862A /* MacroEngine.swift */; }; 2EF09816678369E9CB1B63B8 /* ShortcutModifierMaskNSEventFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C354F219D47B96E53D4DE99F /* ShortcutModifierMaskNSEventFlagsTests.swift */; }; 2F7C69992620D669D794A6F0 /* CaretWordContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D274F82A32C7FB404964428 /* CaretWordContextTests.swift */; }; @@ -206,6 +211,7 @@ 32F8F271C53D0F3782C7375D /* ChromiumAccessibilityEnabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB13342992EEF2BDEEAB1236 /* ChromiumAccessibilityEnabler.swift */; }; 332F2AE6277FB6E871589E4D /* MacroControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C64BFD7273D1B637AD880AA /* MacroControllerTests.swift */; }; 3368DFBAA36527B7BB09EA67 /* ApplicationBundleMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C151BF4D39485E5CFACFECB /* ApplicationBundleMetadataTests.swift */; }; + 33765A26561DCD843A03205E /* TranslationPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */; }; 33EC776945F8CE612A8E85A3 /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E7D1184F0BC707F0A9148DD /* CustomRulesEditor.swift */; }; 35173B2E2394D942043FB086 /* CompletionContentPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384D85534F3C2F40E2FD8781 /* CompletionContentPolicy.swift */; }; 35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */; }; @@ -287,6 +293,7 @@ 477488B0FDAD0A9B76DA9848 /* OpenAICompatibleAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA17364BAFFA56E7DDF682D /* OpenAICompatibleAPIClientTests.swift */; }; 47D099DE9608A174BB0C3C3F /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771EAA74003DAD7D4F3FD199 /* OnboardingTemplateFeatureListTests.swift */; }; 47EFFC4AEBA2848C933BB77C /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */; }; + 4849A63A5DF120418DC4AA1A /* TranslationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1992990B3729259C031994B3 /* TranslationCoordinator.swift */; }; 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; }; 494DA4B28C173F2B3E18DBF9 /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */; }; @@ -301,11 +308,13 @@ 4D2C1A0B4FBEF95CBFB9F481 /* ModelFileValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259095D0A3E9B8DC26CB4B0 /* ModelFileValidatorTests.swift */; }; 4D32F003F78EAAA984E77D83 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2F75C66BECA61A722D3FB /* LLMIOFileHandler.swift */; }; 4D4F02A7187C324A4B43A974 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB051B5B72986C03AB1EE428 /* MenuBarStatusLabelView.swift */; }; + 4D8EAA5D687A54D5428258F6 /* TranslationPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */; }; 4E2DEFF3CA51E8B160B802CA /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3C1C9A9BF1F74C16E4401EC /* CotabbyBrand.swift */; }; 4E8268A5C44B9E5945EBE59D /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7A9C39A0713BD3B442C9AC /* AppUpdateManager.swift */; }; 4ED862FE30A8DAE69C9EC7EF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7024D32C43EAD2C5F8689B2D /* AcknowledgementsView.swift */; }; 4F3CE71B97663B8025779F9C /* fr.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5DCA3E46621815A19300365F /* fr.txt */; }; + 4FEF448AF18494F11C816D80 /* TranslationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */; }; 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; 50CDF4E6CD754A74DDCF67FC /* MacroTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CDB11D96F343194B390321 /* MacroTriggerStateMachine.swift */; }; 50D12741A639F8A367239FBE /* TypefaceEvidenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B45E958CF9BE63A728E273 /* TypefaceEvidenceTests.swift */; }; @@ -327,6 +336,7 @@ 53F946FF09038C1C751C6E53 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A051152D3C503A2339BF0FA /* FoundationModelPromptRenderer.swift */; }; 54091F97C3F64ABB737DC76B /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6EA89178F52F6D5B90CBFE /* LanguageCatalog.swift */; }; 540C1F9A70A5C7DB988FBDD4 /* VisualContextExcerptSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47457FED99065966328047EB /* VisualContextExcerptSelector.swift */; }; + 54199DD4D6FEABFBA4ED1AED /* TranslationEngines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */; }; 54290B1569A7568809014A23 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B69917649DAD33BC90388 /* SystemSettingsWindowLocator.swift */; }; 543B45F8FF4A6AFCB9286521 /* Sparkle-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 70C489EC7EA50C2B103A6B09 /* Sparkle-LICENSE.txt */; }; 5461DD0133FE632B2EB4936A /* BrowserAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589029534F91D2BE26A65263 /* BrowserAppDetectorTests.swift */; }; @@ -335,6 +345,7 @@ 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C25CEF5C997F920C7BF0F /* FieldStyleCache.swift */; }; 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7014E6E969FD4579028BAC4 /* TypingCadence.swift */; }; 556197D64680237CBCCCDD9F /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7736E6B390C822F91CC1DA99 /* OCRTextHygiene.swift */; }; + 559451AFAA8CF6AE2A350552 /* TranslationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1992990B3729259C031994B3 /* TranslationCoordinator.swift */; }; 55DAFC9D06629C23A6868565 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53ED6F2F3354FB3E6065804 /* WelcomeTemplateStepView.swift */; }; 56471188E5F78EE0785D7BF7 /* CotabbyInference-MIT.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2472C76E96009300EBF038D4 /* CotabbyInference-MIT.txt */; }; 564BA8F230F45E378D26B36C /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; @@ -434,6 +445,7 @@ 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 6F3B43828817C2C303E8D16E /* InlineCommandCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */; }; 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50AECDF57800BBB6617620D5 /* TokenHealingBuffer.swift */; }; + 6FEB82EB34BB3C89EF656F9E /* MessageBlockGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */; }; 7008657841ECD4F06A46A9DE /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC4D7129517D678D95D0BC2 /* PromptPolicyTests.swift */; }; 7097797640547007DF552F4E /* SuggestionOverlayStabilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49DB247AACE1B28D45DB52B0 /* SuggestionOverlayStabilityGate.swift */; }; 70A40CE02EF0AF361263BE7E /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871C7A27BAB5A510273D0A19 /* EmojiPickerView.swift */; }; @@ -453,6 +465,7 @@ 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 73A1C073056E07657992AE5A /* CaretTokenPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBE00782EE6813AF3224CD5 /* CaretTokenPosition.swift */; }; 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AEBBBAB9588AA8EDBA41520 /* PermissionAndContextModelTests.swift */; }; + 7418E40356268EA32FF7E2A7 /* TranslationPreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 749C4953D3AFE86AFABA968C /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C5E3A714F5A7818337101C /* OnboardingTemplateTests.swift */; }; 74C07F4568C4FAE1C344E789 /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BD4DE5D9D8C9BB66B78633 /* TextDirectionDetectorTests.swift */; }; @@ -463,6 +476,7 @@ 757696BA01AE112E3B1241EC /* CaretLagPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40016262B83C6C55B6593C7 /* CaretLagPolicy.swift */; }; 764F157C87F3155FBE698A3A /* SuggestionConversationIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022734DBBC384802C7CDB97D /* SuggestionConversationIsolationTests.swift */; }; 765A4E8212962910AA3FBA87 /* InputSuppressionControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DBD75CAF6CAC849D5ED9FA4 /* InputSuppressionControllerTests.swift */; }; + 765CA66166EBB88BA38E33E0 /* ConversationLanguageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */; }; 76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */; }; 76B2F9D2C93958F10F65D26F /* SuggestionSessionReconciliationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEDCE7B9BC5B22080B3A8DF /* SuggestionSessionReconciliationTests.swift */; }; 76D834C8C72F8AFD737D8433 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C557203A5A5F08E68BB11B /* TerminalAppDetectorTests.swift */; }; @@ -500,6 +514,7 @@ 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADC3A68EF5FD9A1E6B5D249 /* CustomRulesTests.swift */; }; 8129ED85DFCF718BFF855D7C /* PixelCaretLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52263602D578987A7ADC776 /* PixelCaretLocatorTests.swift */; }; 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AAC6367483952AB7D59A62 /* CurrentWordExtractor.swift */; }; + 81AEE27758546FC4F77BCE77 /* TranslationCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600F38FBA77948D07934173 /* TranslationCoreTests.swift */; }; 81FCE906A26ED284E8DD46D5 /* ModelDownloadSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFBEB715AF043D3315B6656 /* ModelDownloadSessionDelegate.swift */; }; 82330263D4919D89C293F737 /* DebouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61134664FF616732705A89B /* DebouncePolicyTests.swift */; }; 82AE1426097F86C73537D6CF /* SuggestionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8543EBA09E11A5066880CC91 /* SuggestionResult.swift */; }; @@ -524,12 +539,14 @@ 85DE2B1A62BA24DA615A628E /* HostBundledFontRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA2CFC322EE56151646E745 /* HostBundledFontRegistryTests.swift */; }; 85EDFFAB94CB8577CDBAF49B /* SuggestionDismissalMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6809474A7C1B57F1D13AA6F /* SuggestionDismissalMemoryTests.swift */; }; 85FCDC35AC29F4A89E30F559 /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD7B94FF42D8C538C328E80 /* BundledRuntimeLocatorTests.swift */; }; + 86C1D546D6DE1777DBE6CCC4 /* TranslationHotkeyTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */; }; 8756E3050C45106D7AD188AA /* MacroTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C9DBC2A94B6E805EDAFEA5 /* MacroTriggerStateMachineTests.swift */; }; 87C0A4F8038F912CDFF5C071 /* supported-model-regressions.json in Resources */ = {isa = PBXBuildFile; fileRef = 409BB6D0152A1A9B37DE12F3 /* supported-model-regressions.json */; }; 87CE40ADBFCDBFD266BA9E3F /* InlinePreviewPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */; }; 888FF8B27F8545F1EE9BBA93 /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241105A4F9C29DD8C8D4A91 /* OpenAICompatibleSuggestionEngine.swift */; }; 88AAFB21F06EE37A392081E1 /* SuggestionSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CADFBB03C10309B9048C8E9 /* SuggestionSessionReconciler.swift */; }; 88B5413CB02F1C53E81DC683 /* HostFaceMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623D8B3C927836D7C4002AB8 /* HostFaceMemoryTests.swift */; }; + 88E36607074D2607730360C9 /* TranslationPreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */; }; 89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; 89B0199B226B7B6FCEBBBAC3 /* SuggestionCoordinatorRigTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2471F967A05EB4C8BF7CD242 /* SuggestionCoordinatorRigTestCase.swift */; }; 8AE7B4DE4650D0DA8F6EFFE7 /* GhostTextLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C0792BF971B702DE0A095E /* GhostTextLayout.swift */; }; @@ -619,6 +636,7 @@ A16DEE62FB45155A10E5DF74 /* SuggestionCoordinator+HostMarkedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DAD5637347E83DDCF515568 /* SuggestionCoordinator+HostMarkedText.swift */; }; A1712224D1E7273EDA047544 /* SuggestionCoordinatorWordCompletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867188F95EB41AB11AA8862B /* SuggestionCoordinatorWordCompletionTests.swift */; }; A18051D870C0BEF082053D2C /* GhostFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BB26526298A85454330591 /* GhostFontResolver.swift */; }; + A1807CDAA7D835525D7B7870 /* TranslationHotkeyTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */; }; A1A612C90221E0FE1195754A /* SettingsCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0AEFF86F8210CBE7CFCBAD /* SettingsCategory.swift */; }; A1C4151AAE1CC55282C136BB /* GhostSpaceBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99C3973D57C7F53416CFBF6 /* GhostSpaceBoundary.swift */; }; A1D47F271E70B4FF24FA9E77 /* PermissionHostAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB3DF187FA7AE5A61B035F6 /* PermissionHostAppTests.swift */; }; @@ -672,6 +690,7 @@ AD1BCDC0809B413FEC5F115B /* GhostTextPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29163A3D0BB0F11EEAC2985A /* GhostTextPanelView.swift */; }; AD7940C54015532D2E8973DD /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; }; + ADDA3DD1B4DD52B898774D5E /* TranslationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */; }; AE5D7E6E784F22EC71060E7E /* HostZoomLadder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B09274BE9500E3C41C18B2 /* HostZoomLadder.swift */; }; AE739989AC401113B99018BC /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA31F5FA8D164CB6C30D06 /* SuggestionCoordinator+Input.swift */; }; AE79541055252CA69CFD5DD7 /* CaretWordContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E3D6790D2A3594BCEEA23 /* CaretWordContext.swift */; }; @@ -734,6 +753,7 @@ C059BDAD7E8F82B058201A87 /* OnboardingTemplateRecommenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFD3AFACAAC963DAF59D153 /* OnboardingTemplateRecommenderTests.swift */; }; C080D50FA0ED1F32C92BCC36 /* HuggingFaceModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2E806C1915514651343055 /* HuggingFaceModelsTests.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; + C0FA647FFD677D577859D0FC /* TranslationEngines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */; }; C10A7F3968C1A0B24F1C479D /* SwiftLog-NOTICE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1788B4543337A9E8388B8F6F /* SwiftLog-NOTICE.txt */; }; C14049A1A7C33F8CADDB3675 /* EmojiUsageStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3D991D3536ED989C89AF44D /* EmojiUsageStoreTests.swift */; }; C1ADF50B2D3A3A05CD0FFA68 /* LlamaEvalScoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202086735E28B96BBCE55E6B /* LlamaEvalScoring.swift */; }; @@ -778,6 +798,7 @@ C8682DF05F0B3C3950FB64B1 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B966EA885CB107161FC3006 /* SystemMetricsStore.swift */; }; C8A3B724BB54A1209583B78B /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF8F1A40EAA82773C07F8B9 /* VisualContextStartCoalescer.swift */; }; C8A48355F3821EBD3D964749 /* SignOffCueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */; }; + C8A90B93D5BD4B7CFF175670 /* FewShotTranslationPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */; }; C8B5E45A6F45B307C067CF61 /* SurfaceContextComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B03589D82D0ADCF41B9CD0 /* SurfaceContextComposer.swift */; }; C95343A5A8496627F44233F0 /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */; }; CA959F082DB2C6E95C5743C8 /* GhostBaselinePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25782BF21045F20B9721DB90 /* GhostBaselinePolicyTests.swift */; }; @@ -789,6 +810,7 @@ CCA8847AD15933EC1EB2C238 /* SuggestionFadeInPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B351B5FDF077F8FF49A9A7 /* SuggestionFadeInPolicy.swift */; }; CCE3A8C0CDB6F493D61131F6 /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 266C8BF167118855C56FC7F4 /* BrowserAppDetector.swift */; }; CCFE98D5CB3EEFE56B0704C7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AEB98CEE34847D9A7B2BCA /* StaticTextRunWalkThrottleTests.swift */; }; + CD0C872DDB22D31CC6A169E9 /* ConversationLanguageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */; }; CD5740A7732F4ACA73CB3F64 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B69917649DAD33BC90388 /* SystemSettingsWindowLocator.swift */; }; CDDEF3C29AEACA986034C01C /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB1A3F7D6C0FF28717E6D4C /* SuggestionEngineModels.swift */; }; CE218D30F69D215095E68456 /* AXHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */; }; @@ -797,6 +819,7 @@ CED430DD80E4769920E3C0E5 /* FocusModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7E1691BC93836AE2FA8931 /* FocusModelsTests.swift */; }; CEDE9C238AD33258B36DB509 /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F155556C751FB074B31CCC64 /* WelcomeHeroDemo.swift */; }; CF04C28598AF9A437990C8A4 /* SurfaceContextComposerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652973AF18C20D51F81F76C5 /* SurfaceContextComposerTests.swift */; }; + CF2868D25EBBCE9CAE8A9DD3 /* MessageBlockGrouper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */; }; CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */; }; CF391BC4A3C80C41048D64D1 /* TypoCaseTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B293615FC411551C3FC15528 /* TypoCaseTransfer.swift */; }; CFCBA5D14335960CF994F40D /* GhostFontSizeLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61CA8FEFB45FB1CCA453A /* GhostFontSizeLimits.swift */; }; @@ -938,6 +961,7 @@ F32559EB4BE478324CA18B96 /* EmojiQueryRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626861231138509BCD7BB6EA /* EmojiQueryRunTests.swift */; }; F35738A5EFCE7C2A737F08C9 /* EmojiPickerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */; }; F38776739B0987C0E3F2D343 /* SettingsSearchRankerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */; }; + F399998A7FBAB7115932D7AA /* TranslationOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */; }; F3B72E0E3C700E57BC3B48F8 /* EmojiTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */; }; F3DA1B242F30332AA91C00C5 /* FoundationModelSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */; }; F3FC8AEB8504F7D1BB1C6443 /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC1416A95346CB72BE036FE /* DisplayCoordinateConverter.swift */; }; @@ -974,6 +998,7 @@ FDF71F83A24FBE17F5B63C68 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1E28CF46BF59ABDC3056BF /* ApplicationBundleMetadata.swift */; }; FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5872EC7795CC1EFF6D0D04 /* EngineAndModelPaneView+Power.swift */; }; + FE7EF795922DDAFDDD62F5C2 /* TranslationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4F9870325E80C563192E40 /* TranslationCache.swift */; }; FECBB40B75788B449719170B /* DeepGeometryWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCBD29CF7B0BA5A9CFDEEA3 /* DeepGeometryWalkThrottle.swift */; }; FECD5A1F9317317983B49D34 /* SuggestionDismissalMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F3333CFD174AB34060A0BD /* SuggestionDismissalMemory.swift */; }; FEF2CF888D8709D1FB0D2B20 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 6F27073D2818C0218C3F4370 /* Logging */; }; @@ -1015,6 +1040,7 @@ 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; 081E05039D4CC2BE2EFCEAD4 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 0854896090B568FFB1926D24 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = ""; }; + 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLanguageTracker.swift; sourceTree = ""; }; 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EngineAndModelPaneView+Endpoint.swift"; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; @@ -1032,6 +1058,7 @@ 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrector.swift; sourceTree = ""; }; 12F6FDB6D09851CC5C6649EF /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = ""; }; 13838E1372956FF2CFC5ECCC /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; + 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationEngines.swift; sourceTree = ""; }; 147D8EAD035A66FCA1D07E37 /* HostBaselineCalibratorTypefaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBaselineCalibratorTypefaceTests.swift; sourceTree = ""; }; 14D5E77EB08030489DAFA4DC /* DeviceInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoTests.swift; sourceTree = ""; }; 14E22DCC1852B8E4B235D256 /* WordCountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatter.swift; sourceTree = ""; }; @@ -1048,6 +1075,7 @@ 17B5B1AE537B78F54A32BC63 /* VisualContextExcerptSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextExcerptSelectorTests.swift; sourceTree = ""; }; 17D41C0E6B52554439D51D99 /* TypingSessionEvalScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingSessionEvalScoring.swift; sourceTree = ""; }; 17F3333CFD174AB34060A0BD /* SuggestionDismissalMemory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDismissalMemory.swift; sourceTree = ""; }; + 1992990B3729259C031994B3 /* TranslationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCoordinator.swift; sourceTree = ""; }; 19B257134CCCD0D4370E5287 /* SwiftLog-Apache-2.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "SwiftLog-Apache-2.0.txt"; sourceTree = ""; }; 1A951C04DF783B1F1EFDD1E5 /* BundleVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleVersion.swift; sourceTree = ""; }; 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleEndpointModels.swift; sourceTree = ""; }; @@ -1108,6 +1136,7 @@ 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsDomainTests.swift; sourceTree = ""; }; 2EB3DF187FA7AE5A61B035F6 /* PermissionHostAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHostAppTests.swift; sourceTree = ""; }; 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemUIFocusShadowPolicy.swift; sourceTree = ""; }; + 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FewShotTranslationPrompt.swift; sourceTree = ""; }; 2F93E4EC64A8773A1A7F3E24 /* CompletionRenderMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderMode.swift; sourceTree = ""; }; 2FCD2572B33EB6DDF1F2110A /* EmojiCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalogTests.swift; sourceTree = ""; }; 3003832CAC19ED7AE371E845 /* SuggestionPresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPresentationModels.swift; sourceTree = ""; }; @@ -1242,6 +1271,7 @@ 67332497BB5448AEDA700BA7 /* CurrencyEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyEvaluator.swift; sourceTree = ""; }; 67A9F3C0F7B53B7F0D3BA82A /* PerAppShortcutOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerAppShortcutOverride.swift; sourceTree = ""; }; 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGateTests.swift; sourceTree = ""; }; + 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationOverlayController.swift; sourceTree = ""; }; 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModelsTests.swift; sourceTree = ""; }; 68C4773B9316036F7E594EE1 /* EmojiUsageModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageModels.swift; sourceTree = ""; }; 694D2DD5826C379BA4A6C775 /* SuggestionCoordinatorInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorInputTests.swift; sourceTree = ""; }; @@ -1256,6 +1286,7 @@ 6C896CEDC632FB738D3A42BF /* SignOffCue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCue.swift; sourceTree = ""; }; 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; + 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPreferencesStore.swift; sourceTree = ""; }; 6D8FE1B9BEA0B35C6910E534 /* SuggestionContinuationPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContinuationPlan.swift; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelperTests.swift; sourceTree = ""; }; @@ -1306,6 +1337,7 @@ 7D2F4ED8C7493EF4B967BFF9 /* Aria2Provisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2Provisioner.swift; sourceTree = ""; }; 7DE038A6DF4A16B3EB5CEBD3 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 7F19230243E3B5DE5A76244C /* SignOffCueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOffCueTests.swift; sourceTree = ""; }; + 7F4F9870325E80C563192E40 /* TranslationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCache.swift; sourceTree = ""; }; 7F7E66CFA5AB733CE1F793A8 /* MarkerSelectionSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizerTests.swift; sourceTree = ""; }; 80588CA72AB588807BE1C499 /* TokenHealingBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHealingBufferTests.swift; sourceTree = ""; }; 8159F4DD2F03935DD17BD907 /* EmojiTriggerStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTriggerStateMachine.swift; sourceTree = ""; }; @@ -1407,6 +1439,7 @@ AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; AAE4180FC6B2E085CEE93F23 /* HostTextMetricsProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsProbe.swift; sourceTree = ""; }; AAE89BEAF57DF3A287DF56F1 /* PromptSectionBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudget.swift; sourceTree = ""; }; + AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModels.swift; sourceTree = ""; }; AB84C175EA5B59E118F58C49 /* llama-eval-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "llama-eval-cases.json"; sourceTree = ""; }; AC03B99F8F0C435522D44238 /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; ACD93D7DE258C8E5367A433D /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; @@ -1441,13 +1474,16 @@ B630D1F95FD08BC30E624672 /* DebouncePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncePolicy.swift; sourceTree = ""; }; B68F9EDFE2903996F0E5524E /* MailHeaderFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailHeaderFieldDetectorTests.swift; sourceTree = ""; }; B753DA96CA6BC168E675E61E /* GhostWrapBandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostWrapBandPolicy.swift; sourceTree = ""; }; + B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBlockGrouper.swift; sourceTree = ""; }; B81DD30EB657368AACE9625A /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = ""; }; + B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPaneView.swift; sourceTree = ""; }; B8EBC9F1890DD2FFC4884A5C /* SecureFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetectorTests.swift; sourceTree = ""; }; B9F5D16600EE9D4B76D04A26 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; BA2BBA3187237680274BF80E /* HostTextMetricsCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsCacheTests.swift; sourceTree = ""; }; BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = ""; }; BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarView.swift; sourceTree = ""; }; BB36177FC6C26F5E4CD1AD6E /* InputMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitorTests.swift; sourceTree = ""; }; + BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationHotkeyTap.swift; sourceTree = ""; }; BC07B7CE715C69A63E6123DD /* SuggestionSettingsModelDisabledAppsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelDisabledAppsTests.swift; sourceTree = ""; }; BC4F887528AE74AC0DD30314 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; BC7FA0146AC6B78725105BA4 /* InkBaselineAnalyzerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkBaselineAnalyzerTests.swift; sourceTree = ""; }; @@ -1484,6 +1520,7 @@ C696204AAEBA357129F77646 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; C6A19E3F68A42D2FAC95E000 /* SuggestionTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizer.swift; sourceTree = ""; }; C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = ""; }; + C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationLanguagePolicy.swift; sourceTree = ""; }; C7B8727111208CB506C25C4F /* ContextBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextBuffer.swift; sourceTree = ""; }; C8B2BA5E0AD5E3F1D89531B9 /* FocusedInputPollingSignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedInputPollingSignatureTests.swift; sourceTree = ""; }; C8F55E55BBA67E4BF500832D /* SuggestionConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionConfigurationTests.swift; sourceTree = ""; }; @@ -1519,6 +1556,7 @@ D4DB988251D2B907F316A3B8 /* EmojiSynonymCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalogTests.swift; sourceTree = ""; }; D52263602D578987A7ADC776 /* PixelCaretLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelCaretLocatorTests.swift; sourceTree = ""; }; D5E16CB478D3F015EE0C5D8F /* LlamaPromptCacheHintTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptCacheHintTrackerTests.swift; sourceTree = ""; }; + D600F38FBA77948D07934173 /* TranslationCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationCoreTests.swift; sourceTree = ""; }; D681DDF8E81F868C1BC92CB4 /* BlockBreakAlignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockBreakAlignmentTests.swift; sourceTree = ""; }; D71686BC29B7E0FACFF0B19C /* SpellingLanguageResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingLanguageResolverTests.swift; sourceTree = ""; }; D72CC98CFDA795F8210198E8 /* RuntimeBootstrapModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModelTests.swift; sourceTree = ""; }; @@ -1983,6 +2021,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, + B85ECBA936DAC06F98552271 /* TranslationPaneView.swift */, D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */, ); path = Panes; @@ -2457,6 +2496,7 @@ children = ( 342079C94E7FF7A20AE6FC57 /* InlineFeatures */, AF372CAE8AE04CE493E0ED18 /* Suggestion */, + D635A9BB8F3370AC767BBF23 /* Translation */, 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */, 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */, ); @@ -2664,6 +2704,7 @@ 5154668C834FF09343FD3BE6 /* Settings */, 50D6C35B203AC5C0BF4BB1A8 /* Spelling */, 2D8E398BA5F2ED675A25AAFD /* Suggestion */, + B83C01707055589407F5338B /* Translation */, ); path = Models; sourceTree = ""; @@ -2749,6 +2790,14 @@ path = Evaluators; sourceTree = ""; }; + 9463CB8FA51F378E0DA4396E /* Translation */ = { + isa = PBXGroup; + children = ( + D600F38FBA77948D07934173 /* TranslationCoreTests.swift */, + ); + path = Translation; + sourceTree = ""; + }; 987BC94D9D28389C8CE0406E /* Visual */ = { isa = PBXGroup; children = ( @@ -2776,6 +2825,7 @@ 31F80159F918D5DBF9E39915 /* Settings */, F96313138F50BA4CB6236726 /* Spelling */, C33F213B45FF359E6176B987 /* Suggestion */, + 9463CB8FA51F378E0DA4396E /* Translation */, 0AF20FCEC932776FDD999FD6 /* Utilities */, ); path = Support; @@ -3065,6 +3115,7 @@ 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */, 9A3C5A66AA93E50E4A64ED46 /* OverlayController.swift */, 4A65BCABF4B23690AE3DBE93 /* PixelCaretLocator.swift */, + 683945CBE9B88F1A2D28A254 /* TranslationOverlayController.swift */, ); path = Presentation; sourceTree = ""; @@ -3078,6 +3129,14 @@ path = TestSupport; sourceTree = ""; }; + B83C01707055589407F5338B /* Translation */ = { + isa = PBXGroup; + children = ( + AB05DAD47115DAEF0BFE11AF /* TranslationModels.swift */, + ); + path = Translation; + sourceTree = ""; + }; B9BD4D94E361394D2D4852F2 /* Caching */ = { isa = PBXGroup; children = ( @@ -3268,6 +3327,16 @@ path = Focus; sourceTree = ""; }; + D3CA6AE5C4E79858A3A07282 /* Translation */ = { + isa = PBXGroup; + children = ( + 143AB19919CAE76F0F1B6DCB /* TranslationEngines.swift */, + BBF09B3CEB692504AE84F41E /* TranslationHotkeyTap.swift */, + 6D257852738F461DC1049FF5 /* TranslationPreferencesStore.swift */, + ); + path = Translation; + sourceTree = ""; + }; D55AC5A7C15AD5E2917B168A /* Llama */ = { isa = PBXGroup; children = ( @@ -3291,6 +3360,14 @@ path = Input; sourceTree = ""; }; + D635A9BB8F3370AC767BBF23 /* Translation */ = { + isa = PBXGroup; + children = ( + 1992990B3729259C031994B3 /* TranslationCoordinator.swift */, + ); + path = Translation; + sourceTree = ""; + }; D816DC011F025BC315F10052 /* Focus */ = { isa = PBXGroup; children = ( @@ -3399,6 +3476,7 @@ FFF55D3CE9FF3B16845823F7 /* Runtime */, 6495533696D4461386265D05 /* Spelling */, 3B1B0D8E3400BE4FC26AF6B5 /* Suggestion */, + D3CA6AE5C4E79858A3A07282 /* Translation */, 7CE2AB7F84E13EB0A98D263D /* Updates */, 2574DDA4AFE2DE99E608EF95 /* Visual */, ); @@ -3444,6 +3522,18 @@ path = Settings; sourceTree = ""; }; + EAD863059119DF530FF056B3 /* Translation */ = { + isa = PBXGroup; + children = ( + 09F14F4E5FCCFC79ACC22934 /* ConversationLanguageTracker.swift */, + 2F20E1AB04686FB097239D77 /* FewShotTranslationPrompt.swift */, + B77C56D304F76441BD79ED08 /* MessageBlockGrouper.swift */, + 7F4F9870325E80C563192E40 /* TranslationCache.swift */, + C793AD0B4DC99E072D292EDB /* TranslationLanguagePolicy.swift */, + ); + path = Translation; + sourceTree = ""; + }; F02B51FBFE03C6BF20497A70 /* Input */ = { isa = PBXGroup; children = ( @@ -3545,6 +3635,7 @@ 22548CAEF237D0D8CFCBD171 /* Settings */, 643C6D3D4745CED7E4A6417B /* Spelling */, 81E6FF5F2E0AE29DA29FF3DC /* Suggestion */, + EAD863059119DF530FF056B3 /* Translation */, 9ED3FC5CD8FA7C322F2AC166 /* Utilities */, ); path = Support; @@ -3888,6 +3979,7 @@ DA80B804C506B427A663302D /* ContextLivePreviewField.swift in Sources */, 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */, BFE1811C699B5B87530C3248 /* ControlTokenMarkers.swift in Sources */, + CD0C872DDB22D31CC6A169E9 /* ConversationLanguageTracker.swift in Sources */, B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */, AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, @@ -3929,6 +4021,7 @@ 19EB4D1C2D18C0ADC70BAD29 /* EngineAndModelPaneView+OpenSource.swift in Sources */, C6F2116D9256622BA879E2EE /* EngineAndModelPaneView+Power.swift in Sources */, E6650273045E6F1976AF23CA /* EngineAndModelPaneView.swift in Sources */, + 2E5F38501661C60DE7EFD980 /* FewShotTranslationPrompt.swift in Sources */, D5929DF79BA02EE9B170B4D5 /* FieldEdgeIconIndicatorView.swift in Sources */, 146BD035FE0200D06EE1D7AE /* FieldStyleCache.swift in Sources */, 11D3A5C8DA17BCDB79969FC4 /* FileLogHandler.swift in Sources */, @@ -4010,6 +4103,7 @@ 66E7FB51A0DF5916ABBA15D0 /* MenuBarSections.swift in Sources */, 4D4F02A7187C324A4B43A974 /* MenuBarStatusLabelView.swift in Sources */, 35F1681148A324CBEC797BAF /* MenuBarView.swift in Sources */, + 6FEB82EB34BB3C89EF656F9E /* MessageBlockGrouper.swift in Sources */, 8D83B47447409074AAD1A423 /* MidWordContinuationPolicy.swift in Sources */, 222A03738679FC530E5B19B5 /* MirrorOverlayLayout.swift in Sources */, E6C99AB3E1A56E71F3A518FB /* ModelDownloadManager.swift in Sources */, @@ -4136,6 +4230,15 @@ A62575C4326C9C961A82436F /* TokenHealingBuffer.swift in Sources */, E6A062DEDF942C57A77A1AB7 /* TokenHealingPlan.swift in Sources */, A275EDDC0B43865C587C33AA /* TrailingDuplicationFilter.swift in Sources */, + 107DF2BD1B2616BD36E3C1EC /* TranslationCache.swift in Sources */, + 4849A63A5DF120418DC4AA1A /* TranslationCoordinator.swift in Sources */, + 54199DD4D6FEABFBA4ED1AED /* TranslationEngines.swift in Sources */, + 86C1D546D6DE1777DBE6CCC4 /* TranslationHotkeyTap.swift in Sources */, + 02BD1E581B8CB51A8912D93A /* TranslationLanguagePolicy.swift in Sources */, + ADDA3DD1B4DD52B898774D5E /* TranslationModels.swift in Sources */, + 24EE74F6BE91FC7AF04AB8A7 /* TranslationOverlayController.swift in Sources */, + 33765A26561DCD843A03205E /* TranslationPaneView.swift in Sources */, + 7418E40356268EA32FF7E2A7 /* TranslationPreferencesStore.swift in Sources */, F60A94747732F706FD666925 /* TypefaceEvidence.swift in Sources */, DA4693E09A0790D12F531CCA /* TypefaceMatcher.swift in Sources */, 555501CEB124DFDF657EACA4 /* TypingCadence.swift in Sources */, @@ -4215,6 +4318,7 @@ 76933444A4EF1F1CE5039E9E /* ContextLivePreviewField.swift in Sources */, 2089E4C64C1BFDCDA9F2F721 /* ContextPaneView.swift in Sources */, 1415DED5362F6F343AF63123 /* ControlTokenMarkers.swift in Sources */, + 765CA66166EBB88BA38E33E0 /* ConversationLanguageTracker.swift in Sources */, AA2E09FF7E430D66ECA8ECD5 /* CotabbyApp.swift in Sources */, FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, @@ -4256,6 +4360,7 @@ F45DC37FC763FB932B9AB3FB /* EngineAndModelPaneView+OpenSource.swift in Sources */, FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */, 8B8EA667506EB67DBCA35D57 /* EngineAndModelPaneView.swift in Sources */, + C8A90B93D5BD4B7CFF175670 /* FewShotTranslationPrompt.swift in Sources */, A3ABF1872257F40B14CBA3C2 /* FieldEdgeIconIndicatorView.swift in Sources */, 55243B4055E8067FE3483164 /* FieldStyleCache.swift in Sources */, AC716016F8E26C1711C39F6D /* FileLogHandler.swift in Sources */, @@ -4337,6 +4442,7 @@ 832FB4EEA935CA4D67F4478D /* MenuBarSections.swift in Sources */, C52E6F9FECD2FB173386F836 /* MenuBarStatusLabelView.swift in Sources */, 7807D6217618D982CD301875 /* MenuBarView.swift in Sources */, + CF2868D25EBBCE9CAE8A9DD3 /* MessageBlockGrouper.swift in Sources */, 800D96A3889A915779826D11 /* MidWordContinuationPolicy.swift in Sources */, 2CF9087AE6FDB2A182BA9C85 /* MirrorOverlayLayout.swift in Sources */, 0CF057842A15F79FB3C69462 /* ModelDownloadManager.swift in Sources */, @@ -4463,6 +4569,15 @@ 6FA8CBA189210C9821B03656 /* TokenHealingBuffer.swift in Sources */, F61CFA7A600E266D5A05E26E /* TokenHealingPlan.swift in Sources */, 407D80367A0243FBCC9865D6 /* TrailingDuplicationFilter.swift in Sources */, + FE7EF795922DDAFDDD62F5C2 /* TranslationCache.swift in Sources */, + 559451AFAA8CF6AE2A350552 /* TranslationCoordinator.swift in Sources */, + C0FA647FFD677D577859D0FC /* TranslationEngines.swift in Sources */, + A1807CDAA7D835525D7B7870 /* TranslationHotkeyTap.swift in Sources */, + 1038CF642829E77824F2B25D /* TranslationLanguagePolicy.swift in Sources */, + 4FEF448AF18494F11C816D80 /* TranslationModels.swift in Sources */, + F399998A7FBAB7115932D7AA /* TranslationOverlayController.swift in Sources */, + 4D8EAA5D687A54D5428258F6 /* TranslationPaneView.swift in Sources */, + 88E36607074D2607730360C9 /* TranslationPreferencesStore.swift in Sources */, 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */, 9F8656C10B45DA6699A43787 /* TypefaceMatcher.swift in Sources */, F6A88AAC1A3FE7FB284BFC34 /* TypingCadence.swift in Sources */, @@ -4737,6 +4852,7 @@ C2085C2B2C6F0926A1266867 /* TokenHealingBufferTests.swift in Sources */, 82BEAFC0C5DCE607D45335D3 /* TokenHealingPlanTests.swift in Sources */, 023A1A2A26B192E244192D8D /* TrailingDuplicationFilterTests.swift in Sources */, + 81AEE27758546FC4F77BCE77 /* TranslationCoreTests.swift in Sources */, 50D12741A639F8A367239FBE /* TypefaceEvidenceTests.swift in Sources */, 01527F11AEDBE20935A254B6 /* TypefaceMatcherTests.swift in Sources */, FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 5535fec0..37be444d 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -25,6 +25,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let systemMetricsStore: SystemMetricsStore private let onShowWelcome: () -> Void private let clearEmojiHistory: () -> Void + private let translationPreferences: TranslationPreferencesStore + private let translationService: TranslationService private var settingsWindowController: NSWindowController? @@ -49,7 +51,9 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { qualityMetricsStore: SuggestionQualityMetricsStore, systemMetricsStore: SystemMetricsStore, onShowWelcome: @escaping () -> Void, - clearEmojiHistory: @escaping () -> Void + clearEmojiHistory: @escaping () -> Void, + translationPreferences: TranslationPreferencesStore, + translationService: TranslationService ) { self.appUpdateManager = appUpdateManager self.permissionManager = permissionManager @@ -65,6 +69,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.systemMetricsStore = systemMetricsStore self.onShowWelcome = onShowWelcome self.clearEmojiHistory = clearEmojiHistory + self.translationPreferences = translationPreferences + self.translationService = translationService } /// Shows the settings window, reusing the existing instance if it is already open. @@ -94,6 +100,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { systemMetricsStore: systemMetricsStore, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory, + translationPreferences: translationPreferences, + translationService: translationService, onQuit: { NSApplication.shared.terminate(nil) } ) ) diff --git a/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift new file mode 100644 index 00000000..ecc91246 --- /dev/null +++ b/Cotabby/App/Coordinators/Translation/TranslationCoordinator.swift @@ -0,0 +1,304 @@ +import AppKit +import Combine +import CoreGraphics +import Foundation +import Logging + +/// Runs augmented translation in the apps the user chose: +/// +/// - **Incoming:** every 2 seconds (and shortly after scrolling) it captures the frontmost chat +/// window, reads its text, groups it into messages (`MessageBlockGrouper`), translates those not in +/// the reading language (`TranslationService`), and draws each translation under its message. +/// - **Reply:** when the user types a reply in their reading language in a chat whose language is +/// known, it shows the reply translated into that language under the field and installs a +/// shortcut that replaces the draft with it. It never sends anything. +/// +/// Ownership: built once by `CotabbyAppEnvironment`, started and stopped by `AppDelegate`. It reads +/// the focus snapshot but never changes autocomplete state. Nothing it reads or translates is +/// stored: captures are dropped after OCR, translations live in an in-memory cache. +@MainActor +final class TranslationCoordinator { + private let preferences: TranslationPreferencesStore + private let service: TranslationService + private let capture: WindowScreenshotService + private let overlay: TranslationOverlayController + private let focusModel: FocusTrackingModel + private let inserter: SuggestionInserter + private let hotkey: TranslationHotkeyTap + /// Cotabby's own gates for an app: globally on, not paused, app not disabled, not Low Power Mode. + private let isAllowed: @MainActor (String) -> Bool + + private var conversations = ConversationLanguageTracker() + private var timer: Timer? + private var scrollMonitor: Any? + private var cancellables = Set() + private var incomingTask: Task? + private var lastCaptureSignature: (windowID: CGWindowID, frame: CGRect, hash: Int)? + private var replyTask: Task? + private var offeredReply: (draft: String, translation: String)? + /// The draft currently being translated. Focus updates arrive every ~50 ms; without this, each + /// one would cancel the pending translation of a draft that has not changed. + private var pendingReplyDraft: String? + + /// Most blocks translated per capture; the local model (Macedonian) is limited further because + /// each translation takes about a second. + private static let maximumBlocks = 30 + private static let maximumLocalModelBlocks = 5 + private static let replyDebounceNanoseconds: UInt64 = 700_000_000 + + init( + preferences: TranslationPreferencesStore, + service: TranslationService, + capture: WindowScreenshotService, + overlay: TranslationOverlayController, + focusModel: FocusTrackingModel, + inserter: SuggestionInserter, + hotkey: TranslationHotkeyTap, + isAllowed: @escaping @MainActor (String) -> Bool + ) { + self.preferences = preferences + self.service = service + self.capture = capture + self.overlay = overlay + self.focusModel = focusModel + self.inserter = inserter + self.hotkey = hotkey + self.isAllowed = isAllowed + } + + func start() { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.scheduleIncomingPass() } + } + // Scrolling moves every message; hide stale labels at once and re-read once it settles. + scrollMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.overlay.hideIncoming() + self.lastCaptureSignature = nil + } + } + focusModel.$snapshot + .sink { [weak self] snapshot in self?.observeReply(snapshot) } + .store(in: &cancellables) + preferences.$preferences + .map(\.isEnabled) + .removeDuplicates() + .sink { [weak self] enabled in + guard let self, !enabled else { return } + self.overlay.hideAll() + self.hotkey.remove() + self.service.clearCache() + self.conversations.removeAll() + } + .store(in: &cancellables) + } + + func stop() { + timer?.invalidate() + timer = nil + if let scrollMonitor { NSEvent.removeMonitor(scrollMonitor) } + scrollMonitor = nil + cancellables.removeAll() + incomingTask?.cancel() + replyTask?.cancel() + hotkey.remove() + overlay.hideAll() + } + + // MARK: - Incoming + + private func scheduleIncomingPass() { + guard incomingTask == nil else { return } + incomingTask = Task { [weak self] in + await self?.runIncomingPass() + self?.incomingTask = nil + } + } + + private func runIncomingPass() async { + let prefs = preferences.preferences + guard prefs.isEnabled, prefs.translatesIncoming, + let app = NSWorkspace.shared.frontmostApplication, + let bundleIdentifier = app.bundleIdentifier, + preferences.isActive(forBundleIdentifier: bundleIdentifier), + isAllowed(bundleIdentifier), + CGPreflightScreenCaptureAccess() + else { + overlay.hideIncoming() + lastCaptureSignature = nil + return + } + + do { + let window = try await capture.captureActiveWindow(processIdentifier: app.processIdentifier) + let hash = Self.sampleHash(window.image) + if let last = lastCaptureSignature, last.windowID == window.windowID, last.frame == window.windowFrame, + last.hash == hash { + return + } + overlay.hideIncoming() + + let extractor = ScreenTextExtractor( + maxImageDimension: 2400, maxRecognizedCharacters: 20_000, + recognitionLanguages: Self.recognitionLanguages(reading: prefs.readingLanguage) + ) + let text = try await extractor.extractText(from: window.image) + let lines = text.lines.compactMap { line -> MessageBlockGrouper.Line? in + guard let box = line.boundingBox else { return nil } + return MessageBlockGrouper.Line(text: line.text, confidence: line.confidence, boundingBox: box) + } + let blocks = MessageBlockGrouper.blocks( + from: lines, windowFrame: window.windowFrame, composeFrame: composeFrame(forPID: app.processIdentifier) + ) + let labels = await translate(blocks, readingLanguage: prefs.readingLanguage, + conversationKey: ConversationLanguageTracker.key( + bundleIdentifier: bundleIdentifier, windowTitle: window.windowTitle)) + + // The user may have switched apps or chats while this ran; never draw over the wrong window. + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier else { return } + lastCaptureSignature = (window.windowID, window.windowFrame, hash) + overlay.showIncoming(labels, windowFrame: window.windowFrame) + } catch { + overlay.hideIncoming() + } + } + + /// Translates the most recent messages first (lowest on screen) and records the language of the + /// newest one as the conversation's language. + private func translate(_ blocks: [MessageBlock], readingLanguage: String, conversationKey: String) async -> [TranslationLabel] { + var labels: [TranslationLabel] = [] + var localModelBudget = Self.maximumLocalModelBlocks + var recordedConversation = false + for (index, block) in blocks.reversed().prefix(Self.maximumBlocks).enumerated() { + if Task.isCancelled { break } + guard let detection = TranslationLanguagePolicy.needsTranslation(block.text, readingLanguage: readingLanguage) + else { continue } + if !recordedConversation { + conversations.record(language: detection.language, for: conversationKey) + recordedConversation = true + } + let availability = await service.availability(from: detection.language, to: readingLanguage) + if availability == .localModel { + guard localModelBudget > 0 else { continue } + localModelBudget -= 1 + } else if availability != .ready { + continue + } + guard let result = try? await service.translate(block.text, from: detection.language, to: readingLanguage), + result.translatedText.caseInsensitiveCompare(block.text) != .orderedSame + else { continue } + labels.append(TranslationLabel(id: index, text: result.translatedText, messageFrame: block.frame)) + } + return labels + } + + /// The reply field's frame in the same app, in global top-left points, so its text (the draft) + /// and the chat list beside it are not read as messages. + private func composeFrame(forPID pid: pid_t) -> CGRect? { + guard let input = focusModel.snapshot.context, input.processIdentifier == pid, + let frame = input.elementFrameRect ?? input.inputFrameRect else { return nil } + return ScreenSpace.globalRect(fromCocoa: frame) + } + + /// Vision hints: the reading language plus the scripts the user's chats are likely in. Vision has + /// no Macedonian model; Russian and Ukrainian cover its Cyrillic letters best. + private static func recognitionLanguages(reading: String) -> [String] { + var languages = ["en-US", "tr-TR", "ru-RU", "uk-UA", "de-DE", "fr-FR", "es-ES", "it-IT", "nl-NL"] + if let match = languages.firstIndex(where: { TranslationLanguagePolicy.sameLanguage($0, reading) }) { + languages.insert(languages.remove(at: match), at: 0) + } + return languages + } + + /// A cheap fingerprint of the captured pixels, so an unchanged chat is not re-read every 2 s. + private static func sampleHash(_ image: CGImage) -> Int { + guard let data = image.dataProvider?.data, let bytes = CFDataGetBytePtr(data) else { return 0 } + let length = CFDataGetLength(data) + var hasher = Hasher() + hasher.combine(image.width) + hasher.combine(image.height) + var offset = 0 + while offset < length { + hasher.combine(bytes[offset]) + offset += 997 + } + return hasher.finalize() + } + + // MARK: - Reply + + private func observeReply(_ snapshot: FocusSnapshot) { + let prefs = preferences.preferences + guard prefs.isEnabled, prefs.offersReplyTranslation, + case .supported = snapshot.capability, + let input = snapshot.context, !input.isSecure, + preferences.isActive(forBundleIdentifier: input.bundleIdentifier), + isAllowed(input.bundleIdentifier), + let conversationLanguage = conversations.language( + for: ConversationLanguageTracker.key(bundleIdentifier: input.bundleIdentifier, windowTitle: input.windowTitle)), + !TranslationLanguagePolicy.sameLanguage(conversationLanguage, prefs.readingLanguage) + else { + withdrawReplyOffer() + return + } + + let draft = (input.precedingText + input.trailingText).trimmingCharacters(in: .whitespacesAndNewlines) + if offeredReply?.draft == draft || pendingReplyDraft == draft { return } + withdrawReplyOffer() + guard draft.split(whereSeparator: \.isWhitespace).count >= 2, + TranslationLanguagePolicy.isWritten(in: prefs.readingLanguage, draft) + else { return } + + let fieldFrame = input.elementFrameRect ?? input.inputFrameRect ?? input.caretRect + pendingReplyDraft = draft + replyTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: Self.replyDebounceNanoseconds) + guard let self, !Task.isCancelled else { return } + let result = try? await self.service.translate(draft, from: prefs.readingLanguage, to: conversationLanguage) + guard !Task.isCancelled, self.pendingReplyDraft == draft else { return } + self.pendingReplyDraft = nil + guard let result else { return } + self.offerReply(draft: draft, translation: result.translatedText, language: conversationLanguage, fieldFrame: fieldFrame) + } + } + + private func offerReply(draft: String, translation: String, language: String, fieldFrame: CGRect) { + let prefs = preferences.preferences + offeredReply = (draft, translation) + overlay.showReply( + translation: translation, + languageName: Locale.current.localizedString(forLanguageCode: language) ?? language, + shortcutLabel: prefs.replaceKeyLabel, + below: fieldFrame + ) + hotkey.install(keyCode: prefs.replaceKeyCode, modifiers: prefs.replaceKeyModifiers) { [weak self] in + self?.replaceDraft() + } + } + + /// Replaces the draft only if the field still holds exactly the text that was translated. + private func replaceDraft() { + guard let offeredReply, let input = focusModel.snapshot.context, + (input.precedingText + input.trailingText).trimmingCharacters(in: .whitespacesAndNewlines) == offeredReply.draft + else { + withdrawReplyOffer() + return + } + let translation = offeredReply.translation + withdrawReplyOffer() + if !inserter.replaceFieldText(with: translation) { + CotabbyLogger.app.error("Reply translation could not replace the draft") + } + } + + private func withdrawReplyOffer() { + replyTask?.cancel() + replyTask = nil + pendingReplyDraft = nil + offeredReply = nil + hotkey.remove() + overlay.hideReply() + } +} diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index e29c9e63..5d97e3d4 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -166,6 +166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appUpdateManager.start() suggestionCoordinator.start() inlineCommandCoordinator.start() + environment.translationCoordinator.start() welcomeCoordinator.presentIfNeeded() welcomeCoordinator.presentPermissionReminderIfNeeded() didStartServices = true @@ -248,6 +249,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { focusDebugOverlayController?.hide() suggestionCoordinator.stop() inlineCommandCoordinator.stop() + environment.translationCoordinator.stop() inputMonitor.stop() focusModel.stop() diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index ee4a1618..9644a334 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -40,6 +40,8 @@ final class CotabbyAppEnvironment { let huggingFaceSearchService: HuggingFaceSearchService let performanceMetricsStore: PerformanceMetricsStore let qualityMetricsStore: SuggestionQualityMetricsStore + let translationPreferences: TranslationPreferencesStore + let translationCoordinator: TranslationCoordinator let settingsCoordinator: SettingsCoordinator let activationIndicatorController: ActivationIndicatorController let focusDebugOverlayController: FocusDebugOverlayController? @@ -157,6 +159,34 @@ final class CotabbyAppEnvironment { // to start sampling, so constructing it eagerly here costs nothing. let systemMetricsStore = SystemMetricsStore() let suggestionInserter = SuggestionInserter(suppressionController: suppressionController) + + // Augmented translation: Apple Translation first, the selected Open Source model for pairs + // Apple does not cover. Off until the user turns it on in Settings → Translation. + let translationPreferences = TranslationPreferencesStore() + let translationService = TranslationService( + apple: AppleTranslationEngine(), + local: LocalModelTranslationEngine( + runtimeManager: runtimeManager, + hasModel: { [weak runtimeModel] in runtimeModel?.selectedModelFilename != nil } + ) + ) + let translationCoordinator = TranslationCoordinator( + preferences: translationPreferences, + service: translationService, + capture: WindowScreenshotService(), + overlay: TranslationOverlayController(), + focusModel: focusModel, + inserter: suggestionInserter, + hotkey: TranslationHotkeyTap(suppressionController: suppressionController), + isAllowed: { [weak suggestionSettings, weak lowPowerModeMonitor] bundleIdentifier in + guard let suggestionSettings else { return false } + let settings = suggestionSettings.snapshot + let lowPower = (lowPowerModeMonitor?.isLowPowerModeEnabled ?? false) + && settings.isLowPowerModeAutoDisableEnabled + return settings.isGloballyEnabled && !settings.isTemporarilyPaused && !lowPower + && !settings.disabledAppBundleIdentifiers.contains(bundleIdentifier) + } + ) // Commit accepted text through an IME-safe path (Accessibility / paste) while a composing IME // is active; a synthetic keystroke would be re-absorbed into composition and the accept would // silently fail. @@ -247,7 +277,9 @@ final class CotabbyAppEnvironment { onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() }, - clearEmojiHistory: { emojiUsageStore.clear() } + clearEmojiHistory: { emojiUsageStore.clear() }, + translationPreferences: translationPreferences, + translationService: translationService ) let interactionState = SuggestionInteractionState() @@ -352,6 +384,8 @@ final class CotabbyAppEnvironment { self.huggingFaceSearchService = huggingFaceSearchService self.performanceMetricsStore = performanceMetricsStore self.qualityMetricsStore = qualityMetricsStore + self.translationPreferences = translationPreferences + self.translationCoordinator = translationCoordinator self.settingsCoordinator = settingsCoordinator self.activationIndicatorController = activationIndicatorController self.focusDebugOverlayController = CotabbyDebugOptions.areOverlaysAvailable diff --git a/Cotabby/Models/Translation/TranslationModels.swift b/Cotabby/Models/Translation/TranslationModels.swift new file mode 100644 index 00000000..4b2ade4b --- /dev/null +++ b/Cotabby/Models/Translation/TranslationModels.swift @@ -0,0 +1,96 @@ +import CoreGraphics +import Foundation + +/// File overview: +/// Values for Cotabby's translation feature: translating incoming messages into the user's reading +/// language (shown in an overlay under each message) and offering the user's reply translated into +/// the conversation's language before they send it. +/// +/// Languages are carried as minimal BCP-47 codes ("en", "tr", "mk") rather than an enum, because +/// the set comes from Apple's Translation framework (which varies by macOS version) plus the +/// languages Cotabby can translate with its local model. + +/// How well one language pair can be translated on this Mac. +nonisolated enum TranslationPairAvailability: Equatable, Sendable { + /// Apple Translation has the pair installed; translation is fast and offline. + case ready + /// Apple Translation supports the pair but its language files are not downloaded yet. + case needsDownload + /// Apple Translation does not support the pair; the local model translates it instead. + case localModel + /// Neither engine can translate the pair. + case unavailable +} + +/// The user's translation preferences, persisted by `TranslationPreferencesStore`. +nonisolated struct TranslationPreferences: Equatable, Sendable { + /// Master switch. Off by default: translation reads other people's messages from the screen, + /// which is a choice the user makes, not one Cotabby makes for them. + var isEnabled: Bool + /// The language the user reads; incoming messages are translated into it, and replies written + /// in it are offered in the conversation's language. + var readingLanguage: String + /// Apps whose windows are translated, by bundle identifier. + var appBundleIdentifiers: [String] + var translatesIncoming: Bool + var offersReplyTranslation: Bool + /// The key that replaces the user's draft with its translation. + var replaceKeyCode: CGKeyCode + var replaceKeyModifiers: UInt32 + var replaceKeyLabel: String + + /// Messaging and mail apps offered when the user first turns translation on. + static let suggestedAppBundleIdentifiers = [ + "net.whatsapp.WhatsApp", + "com.microsoft.teams2", + "ru.keepcoder.Telegram", + "com.tinyspeck.slackmacgap", + "com.microsoft.Outlook", + "com.apple.mail", + "com.apple.MobileSMS" + ] + + /// ⌃⌥T: free in the suggested apps, and unlike Tab or Return it cannot send a message. + static let defaultReplaceKeyCode: CGKeyCode = 17 + static let defaultReplaceKeyModifiers: UInt32 = (1 << 2) | (1 << 3) + static let defaultReplaceKeyLabel = "⌃ ⌥ T" + + static let defaults = TranslationPreferences( + isEnabled: false, + readingLanguage: "en", + appBundleIdentifiers: suggestedAppBundleIdentifiers, + translatesIncoming: true, + offersReplyTranslation: true, + replaceKeyCode: defaultReplaceKeyCode, + replaceKeyModifiers: defaultReplaceKeyModifiers, + replaceKeyLabel: defaultReplaceKeyLabel + ) +} + +/// One translated piece of text, with the language it was detected in. +nonisolated struct TranslationResult: Equatable, Sendable { + let sourceText: String + let sourceLanguage: String + let targetLanguage: String + let translatedText: String +} + +nonisolated enum TranslationError: Error, Equatable, LocalizedError { + case unsupportedPair(source: String, target: String) + case needsDownload(source: String, target: String) + case noLocalModel + case emptyResult + + var errorDescription: String? { + switch self { + case let .unsupportedPair(source, target): + return "Translation from \(source) to \(target) isn't available on this Mac." + case let .needsDownload(source, target): + return "Download \(source) and \(target) in System Settings › General › Language & Region › Translation Languages." + case .noLocalModel: + return "This language needs a downloaded Open Source model, and none is selected." + case .emptyResult: + return "The translation came back empty." + } + } +} diff --git a/Cotabby/Services/Presentation/TranslationOverlayController.swift b/Cotabby/Services/Presentation/TranslationOverlayController.swift new file mode 100644 index 00000000..3bda2cd6 --- /dev/null +++ b/Cotabby/Services/Presentation/TranslationOverlayController.swift @@ -0,0 +1,162 @@ +import AppKit +import SwiftUI + +/// One translation drawn under a message. +struct TranslationLabel: Equatable, Identifiable { + let id: Int + let text: String + /// The message it translates, in global points with a top-left origin. + let messageFrame: CGRect +} + +/// Draws translations on screen: labels under incoming messages, and the reply-translation card +/// under the field being typed in. +/// +/// Both panels are borderless, non-activating, and click-through (`ignoresMouseEvents`), like the +/// ghost-text panel (`OverlayController`) and the focus debug overlay, so they never take focus or +/// block clicks in the chat app. The incoming panel covers the chat window and draws every label in +/// one SwiftUI view; it is excluded from screen capture because Cotabby captures the window by +/// itself (`SCContentFilter(desktopIndependentWindow:)`), never the composited screen. +@MainActor +final class TranslationOverlayController { + private var incomingPanel: TranslationPanel? + private var replyPanel: TranslationPanel? + + // MARK: - Incoming messages + + func showIncoming(_ labels: [TranslationLabel], windowFrame: CGRect) { + guard !labels.isEmpty else { + hideIncoming() + return + } + let panel = incomingPanel ?? Self.makePanel() + incomingPanel = panel + let cocoaFrame = ScreenSpace.cocoaRect(fromGlobal: windowFrame) + panel.setFrame(cocoaFrame, display: false) + panel.contentView = NSHostingView(rootView: IncomingTranslationLabels(labels: labels, windowFrame: windowFrame)) + panel.orderFrontRegardless() + } + + func hideIncoming() { + incomingPanel?.orderOut(nil) + } + + // MARK: - Reply card + + /// Shows `translation` under the field at `fieldFrame` (Cocoa coordinates, as Accessibility + /// snapshots report them), or above it when the field sits at the bottom of the screen. + func showReply(translation: String, languageName: String, shortcutLabel: String, below fieldFrame: CGRect) { + let panel = replyPanel ?? Self.makePanel() + replyPanel = panel + let view = NSHostingView(rootView: ReplyTranslationCard( + translation: translation, languageName: languageName, shortcutLabel: shortcutLabel + )) + let width = min(max(fieldFrame.width, 320), 640) + view.frame.size.width = width + let height = view.fittingSize.height + let screen = NSScreen.screens.first { $0.frame.intersects(fieldFrame) } ?? NSScreen.main + let visible = screen?.visibleFrame ?? fieldFrame + var origin = CGPoint(x: fieldFrame.minX, y: fieldFrame.minY - height - 6) + if origin.y < visible.minY { origin.y = fieldFrame.maxY + 6 } + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - width - 8) + panel.contentView = view + panel.setFrame(CGRect(origin: origin, size: CGSize(width: width, height: height)), display: true) + panel.orderFrontRegardless() + } + + func hideReply() { + replyPanel?.orderOut(nil) + } + + func hideAll() { + hideIncoming() + hideReply() + } + + private static func makePanel() -> TranslationPanel { + let panel = TranslationPanel( + contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .statusBar + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + panel.animationBehavior = .none + return panel + } +} + +/// Never key or main: the panels must not steal focus from the chat app. +private final class TranslationPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +/// Converts between global display points with a top-left origin (ScreenCaptureKit, Accessibility) +/// and AppKit's bottom-left Cocoa coordinates, across all screens. +enum ScreenSpace { + @MainActor + static func cocoaRect(fromGlobal rect: CGRect) -> CGRect { + let desktop = NSScreen.screens.map(\.frame).reduce(CGRect.null) { $0.union($1) } + guard !desktop.isNull else { return rect } + return CGRect(x: rect.minX, y: desktop.maxY - rect.maxY, width: rect.width, height: rect.height) + } + + @MainActor + static func globalRect(fromCocoa rect: CGRect) -> CGRect { + cocoaRect(fromGlobal: rect) + } +} + +private struct IncomingTranslationLabels: View { + let labels: [TranslationLabel] + let windowFrame: CGRect + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + ForEach(labels) { label in + Text(label.text) + .font(.system(size: 12)) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(Color.accentColor.opacity(0.5))) + .frame(maxWidth: max(label.messageFrame.width, 220), alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .offset(x: label.messageFrame.minX - windowFrame.minX, + y: label.messageFrame.maxY - windowFrame.minY + 2) + } + } + .frame(width: windowFrame.width, height: windowFrame.height, alignment: .topLeading) + } +} + +private struct ReplyTranslationCard: View { + let translation: String + let languageName: String + let shortcutLabel: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "character.bubble") + Text(languageName).font(.caption.weight(.semibold)) + Spacer(minLength: 8) + Text("\(shortcutLabel) to replace your draft") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(translation) + .font(.system(size: 13)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.accentColor.opacity(0.5))) + .padding(2) + } +} diff --git a/Cotabby/Services/Suggestion/SuggestionInserter.swift b/Cotabby/Services/Suggestion/SuggestionInserter.swift index 1c0af40d..91e45189 100644 --- a/Cotabby/Services/Suggestion/SuggestionInserter.swift +++ b/Cotabby/Services/Suggestion/SuggestionInserter.swift @@ -186,6 +186,36 @@ final class SuggestionInserter { return true } + /// Replaces everything in the focused field with `text`: selects all with a synthetic Cmd-A, then + /// pastes. Used by reply translation, where the whole draft becomes its translation. Paste (not + /// keystrokes) because a translation can be long or multi-line and Electron chat fields drop or + /// reorder long synthetic keystroke bursts; the user's clipboard is restored as for any paste. + /// The paste waits a moment so the Cmd-A has been processed before the menu Paste runs (the + /// AX Paste press is immediate, while the keystroke is queued). Never presses Return. + func replaceFieldText(with text: String) -> Bool { + guard let down = CGEvent(keyboardEventSource: nil, virtualKey: Self.aKeyCode, keyDown: true), + let up = CGEvent(keyboardEventSource: nil, virtualKey: Self.aKeyCode, keyDown: false) else { + lastErrorMessage = "Unable to create a synthetic select-all event." + return false + } + down.flags = .maskCommand + up.flags = .maskCommand + suppressionController.markSynthetic(down) + suppressionController.markSynthetic(up) + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + guard let self else { return } + if !self.insertViaPaste(text) { + CotabbyLogger.suggestion.error("Reply translation paste failed") + } + } + lastErrorMessage = nil + return true + } + + private static let aKeyCode: CGKeyCode = 0x00 + /// 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 diff --git a/Cotabby/Services/Translation/TranslationEngines.swift b/Cotabby/Services/Translation/TranslationEngines.swift new file mode 100644 index 00000000..91e81589 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationEngines.swift @@ -0,0 +1,149 @@ +import Foundation +import Logging +import Translation + +/// One way to translate text on this Mac. +@MainActor +protocol TranslationEngine: AnyObject { + func availability(from source: String, to target: String) async -> TranslationPairAvailability + func translate(_ text: String, from source: String, to target: String) async throws -> String +} + +/// Apple's on-device Translation framework: fast (tens of milliseconds once warm), offline, and the +/// best quality for the ~20 languages it covers. Sessions are created per language pair from the +/// installed language files (`init(installedSource:target:)`, macOS 26) and reused, because the first +/// translation on a session loads its model. +@MainActor +final class AppleTranslationEngine: TranslationEngine { + private var sessions: [String: AnyObject] = [:] + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + guard #available(macOS 26.0, *) else { return .unavailable } + let status = await LanguageAvailability().status( + from: Locale.Language(identifier: source), + to: Locale.Language(identifier: target) + ) + switch status { + case .installed: return .ready + case .supported: return .needsDownload + case .unsupported: return .unavailable + @unknown default: return .unavailable + } + } + + func translate(_ text: String, from source: String, to target: String) async throws -> String { + guard #available(macOS 26.0, *) else { throw TranslationError.unsupportedPair(source: source, target: target) } + switch await availability(from: source, to: target) { + case .ready: break + case .needsDownload: throw TranslationError.needsDownload(source: source, target: target) + case .localModel, .unavailable: throw TranslationError.unsupportedPair(source: source, target: target) + } + let key = "\(source)>\(target)" + let session: TranslationSession + if let existing = sessions[key] as? TranslationSession { + session = existing + } else { + session = TranslationSession( + installedSource: Locale.Language(identifier: source), + target: Locale.Language(identifier: target) + ) + sessions[key] = session + } + let response = try await session.translate(text) + return response.targetText + } +} + +/// Translates with the selected Open Source model for pairs Apple Translation does not cover +/// (Macedonian). The bundled models are base models, so this uses a few-shot prompt +/// (`FewShotTranslationPrompt`) rather than an instruction. It shares the local runtime, and its +/// lock, with autocomplete; a translation waits for an in-flight suggestion and vice versa. +@MainActor +final class LocalModelTranslationEngine: TranslationEngine { + private let runtimeManager: LlamaRuntimeManager + private let hasModel: @MainActor () -> Bool + + init(runtimeManager: LlamaRuntimeManager, hasModel: @escaping @MainActor () -> Bool) { + self.runtimeManager = runtimeManager + self.hasModel = hasModel + } + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + guard FewShotTranslationPrompt.supports(source: source, target: target) else { return .unavailable } + return hasModel() ? .localModel : .unavailable + } + + func translate(_ text: String, from source: String, to target: String) async throws -> String { + guard hasModel() else { throw TranslationError.noLocalModel } + guard let prompt = FewShotTranslationPrompt.prompt(for: text, source: source, target: target) else { + throw TranslationError.unsupportedPair(source: source, target: target) + } + let wordCount = text.split(whereSeparator: \.isWhitespace).count + var options = LlamaGenerationOptions( + // Translations run a little longer than their source in tokens; cap runaway output. + maxPredictionTokens: min(256, wordCount * 4 + 16), + temperature: 0.2, + topK: 20, + topP: 0.9, + minP: 0.05, + repetitionPenalty: 1.05, + seed: nil + ) + // One line per translation: decoding stops at the line break the examples end with. + options.singleLine = true + // The autocomplete stop at the first sentence end would cut a two-sentence message in half. + options.sentenceStopMinimumTokens = .max + let output = try await runtimeManager.generate(prompt: prompt, options: options) + let cleaned = FewShotTranslationPrompt.cleanedOutput(output.text) + guard !cleaned.isEmpty else { throw TranslationError.emptyResult } + return cleaned + } +} + +/// The app's single translation entry point: detects nothing itself, picks the engine for a pair +/// (Apple first, the local model only where Apple cannot), and caches every result in memory. +/// +/// Owned by `CotabbyAppEnvironment`; the incoming-message coordinator and the reply controller use +/// it, and the Translation settings pane reads `availability` for its per-language status. +@MainActor +final class TranslationService { + private let apple: any TranslationEngine + private let local: any TranslationEngine + private var cache = TranslationCache() + + init(apple: any TranslationEngine, local: any TranslationEngine) { + self.apple = apple + self.local = local + } + + func availability(from source: String, to target: String) async -> TranslationPairAvailability { + let appleStatus = await apple.availability(from: source, to: target) + if appleStatus == .ready || appleStatus == .needsDownload { return appleStatus } + return await local.availability(from: source, to: target) + } + + func translate(_ text: String, from source: String, to target: String) async throws -> TranslationResult { + if let cached = cache.value(for: text, target: target) { return cached } + let engine: any TranslationEngine + switch await apple.availability(from: source, to: target) { + case .ready: + engine = apple + case .needsDownload: + // The local model is a weaker stand-in; prefer it only when it can actually run. + engine = await local.availability(from: source, to: target) == .localModel ? local : apple + case .localModel, .unavailable: + engine = local + } + let translated = try await engine.translate(text, from: source, to: target) + let result = TranslationResult( + sourceText: text, sourceLanguage: source, targetLanguage: target, translatedText: translated + ) + cache.store(result) + return result + } + + /// Drops every cached translation, e.g. when translation is turned off. + func clearCache() { + cache.removeAll() + } +} diff --git a/Cotabby/Services/Translation/TranslationHotkeyTap.swift b/Cotabby/Services/Translation/TranslationHotkeyTap.swift new file mode 100644 index 00000000..1ff94c71 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationHotkeyTap.swift @@ -0,0 +1,84 @@ +import CoreGraphics +import Foundation +import Logging + +/// The "replace my draft with its translation" shortcut. +/// +/// A consuming event tap, so the key never reaches the chat app (⌥T would otherwise type "†"). It +/// is installed only while a reply translation is on offer and removed as soon as it is not, so +/// Cotabby sits in the keystroke path for this feature only while the card is visible. Synthetic +/// events Cotabby posts itself are ignored, matching the other taps (`InputMonitor`). +@MainActor +final class TranslationHotkeyTap { + private let suppressionController: InputSuppressionController + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var keyCode: CGKeyCode = 0 + private var modifiers: UInt32 = 0 + private var onPress: (() -> Void)? + + init(suppressionController: InputSuppressionController) { + self.suppressionController = suppressionController + } + + var isInstalled: Bool { tap != nil } + + func install(keyCode: CGKeyCode, modifiers: UInt32, onPress: @escaping () -> Void) { + self.keyCode = keyCode + self.modifiers = modifiers + self.onPress = onPress + guard tap == nil else { return } + + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let owner = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + return MainActor.assumeIsolated { owner.handle(type: type, event: event) } + } + guard let created = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + CotabbyLogger.app.warning("Failed to create the translation shortcut tap") + return + } + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, created, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: created, enable: true) + tap = created + runLoopSource = source + } + + func remove() { + onPress = nil + guard let tap else { return } + CGEvent.tapEnable(tap: tap, enable: false) + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + CFMachPortInvalidate(tap) + self.tap = nil + runLoopSource = nil + } + + private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + return Unmanaged.passUnretained(event) + case .keyDown: + guard !suppressionController.isSynthetic(event), + CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) == keyCode, + ShortcutModifierMask(eventFlags: event.flags).rawValue == modifiers, + let onPress + else { return Unmanaged.passUnretained(event) } + onPress() + return nil + default: + return Unmanaged.passUnretained(event) + } + } +} diff --git a/Cotabby/Services/Translation/TranslationPreferencesStore.swift b/Cotabby/Services/Translation/TranslationPreferencesStore.swift new file mode 100644 index 00000000..487f74c3 --- /dev/null +++ b/Cotabby/Services/Translation/TranslationPreferencesStore.swift @@ -0,0 +1,88 @@ +import Combine +import CoreGraphics +import Foundation + +/// Owns the translation preferences and persists them in `UserDefaults`. +/// +/// Kept out of `SuggestionSettingsModel` because translation is its own subsystem: nothing in the +/// suggestion pipeline reads these values, and the incoming/outgoing translation coordinators and +/// the Translation settings pane are their only readers. Built once by `CotabbyAppEnvironment`. +@MainActor +final class TranslationPreferencesStore: ObservableObject { + @Published private(set) var preferences: TranslationPreferences + + private let userDefaults: UserDefaults + + private enum Key { + static let isEnabled = "cotabbyTranslationEnabled" + static let readingLanguage = "cotabbyTranslationReadingLanguage" + static let apps = "cotabbyTranslationApps" + static let translatesIncoming = "cotabbyTranslationIncoming" + static let offersReply = "cotabbyTranslationReplies" + static let replaceKeyCode = "cotabbyTranslationReplaceKeyCode" + static let replaceKeyModifiers = "cotabbyTranslationReplaceKeyModifiers" + static let replaceKeyLabel = "cotabbyTranslationReplaceKeyLabel" + } + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + let defaults = TranslationPreferences.defaults + preferences = TranslationPreferences( + isEnabled: userDefaults.object(forKey: Key.isEnabled) as? Bool ?? defaults.isEnabled, + readingLanguage: userDefaults.string(forKey: Key.readingLanguage) ?? defaults.readingLanguage, + appBundleIdentifiers: userDefaults.stringArray(forKey: Key.apps) ?? defaults.appBundleIdentifiers, + translatesIncoming: userDefaults.object(forKey: Key.translatesIncoming) as? Bool ?? defaults.translatesIncoming, + offersReplyTranslation: userDefaults.object(forKey: Key.offersReply) as? Bool ?? defaults.offersReplyTranslation, + replaceKeyCode: (userDefaults.object(forKey: Key.replaceKeyCode) as? Int).map { CGKeyCode($0) } + ?? defaults.replaceKeyCode, + replaceKeyModifiers: (userDefaults.object(forKey: Key.replaceKeyModifiers) as? Int).map { UInt32($0) } + ?? defaults.replaceKeyModifiers, + replaceKeyLabel: userDefaults.string(forKey: Key.replaceKeyLabel) ?? defaults.replaceKeyLabel + ) + } + + /// Whether translation applies to windows of this app right now. + func isActive(forBundleIdentifier bundleIdentifier: String?) -> Bool { + guard preferences.isEnabled, let bundleIdentifier else { return false } + return preferences.appBundleIdentifiers.contains(bundleIdentifier) + } + + func setEnabled(_ enabled: Bool) { + update(\.isEnabled, enabled, key: Key.isEnabled) + } + + func setReadingLanguage(_ language: String) { + update(\.readingLanguage, language, key: Key.readingLanguage) + } + + func setTranslatesIncoming(_ enabled: Bool) { + update(\.translatesIncoming, enabled, key: Key.translatesIncoming) + } + + func setOffersReplyTranslation(_ enabled: Bool) { + update(\.offersReplyTranslation, enabled, key: Key.offersReply) + } + + func setApp(_ bundleIdentifier: String, included: Bool) { + var apps = preferences.appBundleIdentifiers.filter { $0 != bundleIdentifier } + if included { apps.append(bundleIdentifier) } + update(\.appBundleIdentifiers, apps, key: Key.apps) + } + + func setReplaceKey(keyCode: CGKeyCode, modifiers: UInt32, label: String) { + guard preferences.replaceKeyCode != keyCode || preferences.replaceKeyModifiers != modifiers + || preferences.replaceKeyLabel != label else { return } + preferences.replaceKeyCode = keyCode + preferences.replaceKeyModifiers = modifiers + preferences.replaceKeyLabel = label + userDefaults.set(Int(keyCode), forKey: Key.replaceKeyCode) + userDefaults.set(Int(modifiers), forKey: Key.replaceKeyModifiers) + userDefaults.set(label, forKey: Key.replaceKeyLabel) + } + + private func update(_ keyPath: WritableKeyPath, _ value: Value, key: String) { + guard preferences[keyPath: keyPath] != value else { return } + preferences[keyPath: keyPath] = value + userDefaults.set(value, forKey: key) + } +} diff --git a/Cotabby/Services/Visual/ScreenTextExtractor.swift b/Cotabby/Services/Visual/ScreenTextExtractor.swift index 39ed30b4..09e12db6 100644 --- a/Cotabby/Services/Visual/ScreenTextExtractor.swift +++ b/Cotabby/Services/Visual/ScreenTextExtractor.swift @@ -78,13 +78,19 @@ struct ScreenTextExtractor: ScreenTextExtracting { let maxImageDimension: Int let maxRecognizedCharacters: Int + /// Vision language hints, in priority order ("tr-TR", "ru-RU"). Nil keeps Vision's default, which + /// is what screen context has always used; translation passes the chat's languages so accented + /// and Cyrillic letters are read correctly. + let recognitionLanguages: [String]? init( maxImageDimension: Int = VisualContextConfiguration.default.maxImageDimension, - maxRecognizedCharacters: Int = VisualContextConfiguration.default.maxRecognizedCharacters + maxRecognizedCharacters: Int = VisualContextConfiguration.default.maxRecognizedCharacters, + recognitionLanguages: [String]? = nil ) { self.maxImageDimension = maxImageDimension self.maxRecognizedCharacters = maxRecognizedCharacters + self.recognitionLanguages = recognitionLanguages } /// Performs OCR asynchronously so the main actor is not blocked by Vision processing. @@ -185,6 +191,9 @@ struct ScreenTextExtractor: ScreenTextExtracting { request.recognitionLevel = .accurate request.usesLanguageCorrection = true request.minimumTextHeight = 0.008 + if let recognitionLanguages = self.recognitionLanguages { + request.recognitionLanguages = recognitionLanguages + } do { let handler = VNImageRequestHandler(cgImage: preparedImage, options: [:]) diff --git a/Cotabby/Services/Visual/WindowScreenshotService.swift b/Cotabby/Services/Visual/WindowScreenshotService.swift index 517aaa1d..e4f92341 100644 --- a/Cotabby/Services/Visual/WindowScreenshotService.swift +++ b/Cotabby/Services/Visual/WindowScreenshotService.swift @@ -23,6 +23,15 @@ struct CapturedWindowScreenshot { /// ScreenCaptureKit is permissioned, asynchronous, and window-manager dependent. Keeping this /// protocol narrow lets `ScreenshotContextGenerator` tests focus on context policy instead of /// requiring a live macOS desktop capture. +/// One whole app window, captured for reading its text. `windowFrame` is in global display points +/// with a top-left origin (ScreenCaptureKit's space), the space OCR boxes are mapped into. +struct CapturedAppWindow { + let image: CGImage + let windowID: CGWindowID + let windowFrame: CGRect + let windowTitle: String? +} + protocol WindowScreenshotCapturing { func captureSnapshot( around context: FocusedInputSnapshot, @@ -138,6 +147,38 @@ struct WindowScreenshotService: WindowScreenshotCapturing { return CapturedWindowScreenshot(image: image, windowTitle: matchingWindow.title, focusBounds: focusBounds) } + /// Captures the whole active window of an app, for reading what is on screen (incoming + /// messages to translate) rather than the area around a text field. Returns the window's frame + /// in global display points (top-left origin) so text positions found in the image can be mapped + /// back onto the screen. The capture contains only that window, never Cotabby's own overlays. + func captureActiveWindow(processIdentifier: pid_t) async throws -> CapturedAppWindow { + guard CGPreflightScreenCaptureAccess() else { + throw WindowScreenshotError.screenRecordingPermissionMissing + } + let shareableContent = try await currentShareableContent() + try Task.checkCancellation() + let candidates = shareableContent.windows.filter { + $0.owningApplication?.processID == processIdentifier && $0.isOnScreen && $0.windowLayer == 0 + } + guard let window = candidates.first(where: \.isActive) ?? candidates.max(by: { + $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height + }) else { + throw WindowScreenshotError.noVisibleWindowForProcess(processIdentifier) + } + let scale = backingScaleFactor(for: window.frame) + let configuration = SCStreamConfiguration() + configuration.sourceRect = CGRect(origin: .zero, size: window.frame.size) + configuration.width = max(Int((window.frame.width * scale).rounded(.up)), 1) + configuration.height = max(Int((window.frame.height * scale).rounded(.up)), 1) + configuration.showsCursor = false + let image = try await captureImage( + filter: SCContentFilter(desktopIndependentWindow: window), + configuration: configuration + ) + try Task.checkCancellation() + return CapturedAppWindow(image: image, windowID: window.windowID, windowFrame: window.frame, windowTitle: window.title) + } + private func snapshotRect( around context: FocusedInputSnapshot, windowFrame: CGRect, diff --git a/Cotabby/Support/Translation/ConversationLanguageTracker.swift b/Cotabby/Support/Translation/ConversationLanguageTracker.swift new file mode 100644 index 00000000..a2b4020f --- /dev/null +++ b/Cotabby/Support/Translation/ConversationLanguageTracker.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Remembers which language each conversation is in, so a reply can be offered in it. +/// +/// The incoming-message reader reports the language of the lowest (most recent) translated message +/// in a chat window; the reply controller asks for it when the user starts typing in that window. +/// Conversations are keyed by app plus window title, which in chat apps is the contact or channel +/// name. Kept in memory only and capped, like the translation cache. +nonisolated struct ConversationLanguageTracker: Sendable { + private struct Entry: Sendable { + let language: String + let updatedAt: Date + } + + private let capacity: Int + private var entries: [String: Entry] = [:] + + init(capacity: Int = 100) { + self.capacity = max(1, capacity) + } + + static func key(bundleIdentifier: String, windowTitle: String?) -> String { + "\(bundleIdentifier)|\(windowTitle ?? "")" + } + + mutating func record(language: String, for key: String, at date: Date = Date()) { + entries[key] = Entry(language: language, updatedAt: date) + if entries.count > capacity, let oldest = entries.min(by: { $0.value.updatedAt < $1.value.updatedAt })?.key { + entries[oldest] = nil + } + } + + func language(for key: String) -> String? { + entries[key]?.language + } + + mutating func removeAll() { + entries = [:] + } +} diff --git a/Cotabby/Support/Translation/FewShotTranslationPrompt.swift b/Cotabby/Support/Translation/FewShotTranslationPrompt.swift new file mode 100644 index 00000000..a97d1bbc --- /dev/null +++ b/Cotabby/Support/Translation/FewShotTranslationPrompt.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Builds the prompt that lets a base (non-chat) language model translate, and cleans its output. +/// +/// A base model cannot follow "translate this"; it continues text. Showing it a few labeled +/// sentence pairs and ending with the source sentence plus the target label makes the most likely +/// continuation the translation itself. The examples are everyday chat lines so the register +/// matches messages, and each pair is one line so decoding stops at the line break. +nonisolated enum FewShotTranslationPrompt { + /// Parallel example sentences, keyed by language code. Every language listed here has the + /// same sentences in the same order. + private static let examples: [String: [String]] = [ + "en": [ + "I will call you after the meeting.", + "Can you send me the document today?", + "Thank you, see you tomorrow." + ], + "mk": [ + "Ќе ти се јавам после состанокот.", + "Можеш ли да ми го испратиш документот денес?", + "Ти благодарам, се гледаме утре." + ], + "tr": [ + "Toplantıdan sonra seni arayacağım.", + "Belgeyi bana bugün gönderebilir misin?", + "Teşekkürler, yarın görüşürüz." + ] + ] + + /// Languages this prompt can translate between (both sides need examples). + static func supports(source: String, target: String) -> Bool { + examples[TranslationLanguagePolicy.baseCode(source)] != nil + && examples[TranslationLanguagePolicy.baseCode(target)] != nil + } + + static func prompt(for text: String, source: String, target: String) -> String? { + let sourceCode = TranslationLanguagePolicy.baseCode(source) + let targetCode = TranslationLanguagePolicy.baseCode(target) + guard let sourceExamples = examples[sourceCode], let targetExamples = examples[targetCode] else { return nil } + let sourceLabel = label(sourceCode) + let targetLabel = label(targetCode) + var lines: [String] = [] + for (source, target) in zip(sourceExamples, targetExamples) { + lines.append("\(sourceLabel): \(source)") + lines.append("\(targetLabel): \(target)") + lines.append("") + } + // One line per message: the model stops at the line break, and a multi-line message would + // otherwise end the translation after its first line. + let singleLine = text.split(whereSeparator: \.isNewline).joined(separator: " ") + lines.append("\(sourceLabel): \(singleLine)") + lines.append("\(targetLabel):") + return lines.joined(separator: "\n") + } + + /// The translation from the model's raw continuation: the first line, without any label the + /// model started repeating. + static func cleanedOutput(_ raw: String) -> String { + var line = raw.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" + for code in examples.keys { + let marker = " \(label(code)):" + if let range = line.range(of: marker) { line = String(line[.. String { + Locale(identifier: "en").localizedString(forLanguageCode: code) ?? code + } +} diff --git a/Cotabby/Support/Translation/MessageBlockGrouper.swift b/Cotabby/Support/Translation/MessageBlockGrouper.swift new file mode 100644 index 00000000..a21f9b64 --- /dev/null +++ b/Cotabby/Support/Translation/MessageBlockGrouper.swift @@ -0,0 +1,99 @@ +import CoreGraphics +import Foundation + +/// One message as read from the screen: its text and where it sits, in global display points with a +/// top-left origin (ScreenCaptureKit / Accessibility space). +nonisolated struct MessageBlock: Equatable, Sendable { + let text: String + let frame: CGRect +} + +/// Turns recognized text lines from a chat window into message blocks. +/// +/// Pure geometry and text rules, so it is tested on recorded OCR output. It is conservative: when +/// in doubt it splits rather than merges, because a translation spanning two people's messages is +/// worse than two separate ones. +nonisolated enum MessageBlockGrouper { + struct Line: Equatable, Sendable { + let text: String + let confidence: Float + /// Vision's normalized box: unit square, bottom-left origin, relative to the captured image. + let boundingBox: CGRect + } + + /// Lines below this OCR confidence are usually icons, avatars, or reactions read as text. + static let minimumConfidence: Float = 0.5 + + /// - Parameters: + /// - windowFrame: the captured window, global points, top-left origin. + /// - composeFrame: the focused reply field, if any. Messages are read only above it and from + /// its left edge rightwards, which excludes the chat list and the reply being typed. + static func blocks(from lines: [Line], windowFrame: CGRect, composeFrame: CGRect?) -> [MessageBlock] { + let region = conversationRegion(windowFrame: windowFrame, composeFrame: composeFrame) + let placed: [(text: String, frame: CGRect)] = lines.compactMap { line in + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.confidence >= minimumConfidence, !isChrome(text) else { return nil } + let frame = globalFrame(of: line.boundingBox, in: windowFrame) + guard region.contains(CGPoint(x: frame.midX, y: frame.midY)) else { return nil } + return (text, frame) + } + .sorted { $0.frame.minY == $1.frame.minY ? $0.frame.minX < $1.frame.minX : $0.frame.minY < $1.frame.minY } + + var blocks: [(texts: [String], frame: CGRect, lineHeight: CGFloat)] = [] + for line in placed { + if let last = blocks.last, continues(last.frame, lineHeight: last.lineHeight, with: line.frame) { + blocks[blocks.count - 1].texts.append(line.text) + blocks[blocks.count - 1].frame = last.frame.union(line.frame) + } else { + blocks.append(([line.text], line.frame, line.frame.height)) + } + } + return blocks.map { MessageBlock(text: $0.texts.joined(separator: " "), frame: $0.frame) } + } + + /// Maps a Vision box into global points (top-left origin) for a capture of `windowFrame`. + static func globalFrame(of box: CGRect, in windowFrame: CGRect) -> CGRect { + CGRect( + x: windowFrame.minX + box.minX * windowFrame.width, + y: windowFrame.minY + (1 - box.maxY) * windowFrame.height, + width: box.width * windowFrame.width, + height: box.height * windowFrame.height + ) + } + + // MARK: - Rules + + /// The part of the window that holds the conversation. With a reply field, that is everything + /// above it from its left edge (minus a small margin) to the window's right edge; without one, + /// the window below its title bar. + private static func conversationRegion(windowFrame: CGRect, composeFrame: CGRect?) -> CGRect { + let titleBar: CGFloat = 52 + guard let composeFrame, windowFrame.intersects(composeFrame) else { + return CGRect(x: windowFrame.minX, y: windowFrame.minY + titleBar, + width: windowFrame.width, height: max(0, windowFrame.height - titleBar)) + } + let left = max(windowFrame.minX, composeFrame.minX - 24) + let top = windowFrame.minY + titleBar + return CGRect(x: left, y: top, width: windowFrame.maxX - left, height: max(0, composeFrame.minY - top)) + } + + /// The next line belongs to the same message when it starts right below (within 0.8 line + /// heights) and lines up with the block's left edge or overlaps it horizontally. + private static func continues(_ block: CGRect, lineHeight: CGFloat, with line: CGRect) -> Bool { + let gap = line.minY - block.maxY + guard gap >= -lineHeight * 0.3, gap <= lineHeight * 0.8 else { return false } + let alignedLeft = abs(line.minX - block.minX) <= lineHeight * 1.5 + let overlaps = line.minX < block.maxX && line.maxX > block.minX + return alignedLeft || overlaps + } + + /// Timestamps, read receipts, and other non-message text chat apps draw. + private static func isChrome(_ text: String) -> Bool { + guard text.count >= 2 else { return true } + let letters = text.filter(\.isLetter).count + if letters == 0 { return true } + let time = #"^\d{1,2}[:.]\d{2}(\s?[AaPp][Mm])?$"# + if text.range(of: time, options: .regularExpression) != nil { return true } + return false + } +} diff --git a/Cotabby/Support/Translation/TranslationCache.swift b/Cotabby/Support/Translation/TranslationCache.swift new file mode 100644 index 00000000..c7dd8402 --- /dev/null +++ b/Cotabby/Support/Translation/TranslationCache.swift @@ -0,0 +1,49 @@ +import Foundation + +/// In-memory least-recently-used cache of translations. +/// +/// Chats re-render the same messages constantly (every scroll, every capture), so translating each +/// one once and reusing it is what keeps the overlay cheap. Nothing here is ever written to disk: +/// translations are other people's messages, and they vanish when Cotabby quits. +nonisolated struct TranslationCache: Sendable { + private struct Key: Hashable, Sendable { + let text: String + let target: String + } + + private let capacity: Int + private var values: [Key: TranslationResult] = [:] + private var order: [Key] = [] + + init(capacity: Int = 500) { + self.capacity = max(1, capacity) + } + + var count: Int { values.count } + + mutating func value(for text: String, target: String) -> TranslationResult? { + let key = Key(text: text, target: target) + guard let value = values[key] else { return nil } + touch(key) + return value + } + + mutating func store(_ result: TranslationResult) { + let key = Key(text: result.sourceText, target: result.targetLanguage) + values[key] = result + touch(key) + while order.count > capacity { + values[order.removeFirst()] = nil + } + } + + mutating func removeAll() { + values = [:] + order = [] + } + + private mutating func touch(_ key: Key) { + if let index = order.firstIndex(of: key) { order.remove(at: index) } + order.append(key) + } +} diff --git a/Cotabby/Support/Translation/TranslationLanguagePolicy.swift b/Cotabby/Support/Translation/TranslationLanguagePolicy.swift new file mode 100644 index 00000000..a507b410 --- /dev/null +++ b/Cotabby/Support/Translation/TranslationLanguagePolicy.swift @@ -0,0 +1,96 @@ +import Foundation +import NaturalLanguage + +/// Decides which language a piece of text is in and whether it needs translating. +/// +/// Pure apart from Apple's `NLLanguageRecognizer`, whose decision is wrapped by a deterministic +/// rule (`confidentLanguage`) so tests do not depend on Natural Language's model probabilities. +/// +/// It is conservative on purpose: a wrong "this is Turkish" guess would put a nonsense +/// translation under a message, so short text, links, numbers, and low-confidence guesses are +/// left alone. +nonisolated enum TranslationLanguagePolicy { + struct Detection: Equatable, Sendable { + let language: String + let confidence: Double + } + + /// Below this many letters, language identification is guesswork ("ok", "haha", "👍"). + static let minimumLetters = 12 + static let minimumConfidence = 0.8 + + /// The detected language of `text`, or nil when it is too short or ambiguous. + static func detect(_ text: String) -> Detection? { + let letters = text.filter(\.isLetter) + guard letters.count >= minimumLetters, !looksLikeLinkOrCode(text) else { return nil } + + if let macedonian = macedonianByAlphabet(text) { return macedonian } + + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + let hypotheses = recognizer.languageHypotheses(withMaximum: 3) + .map { (code: $0.key.rawValue, score: $0.value) } + guard let best = confidentLanguage(from: hypotheses) else { return nil } + // Natural Language has no Macedonian model and labels Macedonian text Bulgarian. Text that + // uses only letters the Macedonian alphabet has is far more likely Macedonian for a user + // who writes it; genuine Bulgarian almost always uses ъ, щ, ь, ю or я. + if best.language == "bg", usesOnlyMacedonianCyrillic(text) { + return Detection(language: "mk", confidence: best.confidence) + } + return best + } + + /// The detection when `text` should be translated into `readingLanguage`, else nil. + static func needsTranslation(_ text: String, readingLanguage: String) -> Detection? { + guard let detection = detect(text), !sameLanguage(detection.language, readingLanguage) else { return nil } + return detection + } + + /// Whether the user's draft is written in their reading language (the reply case). + static func isWritten(in language: String, _ text: String) -> Bool { + guard let detection = detect(text) else { return false } + return sameLanguage(detection.language, language) + } + + static func confidentLanguage(from hypotheses: [(code: String, score: Double)]) -> Detection? { + guard let best = hypotheses.max(by: { $0.score < $1.score }), best.score >= minimumConfidence else { return nil } + return Detection(language: best.code, confidence: best.score) + } + + /// "en-US" and "en" are the same reading language. + static func sameLanguage(_ lhs: String, _ rhs: String) -> Bool { + baseCode(lhs) == baseCode(rhs) + } + + static func baseCode(_ code: String) -> String { + String(code.lowercased().split(whereSeparator: { $0 == "-" || $0 == "_" }).first ?? "") + } + + // MARK: - Macedonian + + /// Letters only the Macedonian alphabet has (Serbian shares ј љ њ џ but not ѓ ќ ѕ). + private static let macedonianOnlyLetters: Set = ["ѓ", "ќ", "ѕ"] + /// Cyrillic letters absent from Macedonian (Russian, Bulgarian, Ukrainian). + private static let nonMacedonianCyrillic: Set = ["ъ", "щ", "ь", "ю", "я", "ы", "э", "ё", "й", "і", "ї", "є"] + + private static func macedonianByAlphabet(_ text: String) -> Detection? { + let lowered = text.lowercased() + guard lowered.contains(where: macedonianOnlyLetters.contains), + !lowered.contains(where: nonMacedonianCyrillic.contains) else { return nil } + return Detection(language: "mk", confidence: 0.95) + } + + private static func usesOnlyMacedonianCyrillic(_ text: String) -> Bool { + !text.lowercased().contains(where: nonMacedonianCyrillic.contains) + } + + // MARK: - Noise + + private static func looksLikeLinkOrCode(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") || trimmed.hasPrefix("www.") { return true } + // Mostly symbols and digits (codes, amounts, phone numbers): nothing to translate. + let letters = trimmed.filter(\.isLetter).count + return Double(letters) / Double(max(trimmed.count, 1)) < 0.5 + } +} diff --git a/Cotabby/UI/Settings/Panes/TranslationPaneView.swift b/Cotabby/UI/Settings/Panes/TranslationPaneView.swift new file mode 100644 index 00000000..caabc5bf --- /dev/null +++ b/Cotabby/UI/Settings/Panes/TranslationPaneView.swift @@ -0,0 +1,211 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Settings → Translation: augmented translation of incoming messages and replies. +/// +/// Presentation only. Preferences live in `TranslationPreferencesStore`; the per-language status +/// asks `TranslationService`, which knows what Apple Translation has installed and whether the +/// local model can cover the rest. +struct TranslationPaneView: View { + @ObservedObject var preferences: TranslationPreferencesStore + let service: TranslationService + + @State private var statuses: [String: TranslationPairAvailability] = [:] + @State private var isRecordingShortcut = false + + /// Chat languages whose status is shown; the user's own from their Writing languages are + /// common ones, and Macedonian shows how the local-model fallback is reported. + private static let statusLanguages = ["tr", "mk", "de", "fr", "es", "it", "ru", "nl", "ar", "zh"] + + var body: some View { + SettingsPaneScaffold { + Section("Translation") { + Toggle(isOn: binding(\.isEnabled, preferences.setEnabled)) { + SettingsRowLabel( + title: "Translate Messages", + description: "In the apps below, show translations of messages in other languages, " + + "and offer your replies in the chat's language. Everything runs on this Mac.", + systemImage: "character.bubble" + ) + } + .settingsItem(.translationEnabled) + + Picker(selection: binding(\.readingLanguage, preferences.setReadingLanguage)) { + ForEach(Self.readingLanguageChoices, id: \.self) { code in + Text(Self.name(code)).tag(code) + } + } label: { + SettingsRowLabel( + title: "My Language", + description: "Messages are translated into this language, and replies you write in it are offered in the chat's language.", + systemImage: "person.wave.2" + ) + } + .disabled(!preferences.preferences.isEnabled) + + Toggle(isOn: binding(\.translatesIncoming, preferences.setTranslatesIncoming)) { + SettingsRowLabel( + title: "Translate Incoming Messages", + description: "Reads the chat window every few seconds and shows each translation under its message. Needs Screen Recording.", + systemImage: "text.bubble" + ) + } + .disabled(!preferences.preferences.isEnabled) + + Toggle(isOn: binding(\.offersReplyTranslation, preferences.setOffersReplyTranslation)) { + SettingsRowLabel( + title: "Offer Reply Translation", + description: "When you write in your language, show your reply in the chat's language. " + + "Press \(preferences.preferences.replaceKeyLabel) to replace your draft; you still press Return to send.", + systemImage: "arrowshape.turn.up.left" + ) + } + .disabled(!preferences.preferences.isEnabled) + + LabeledContent { + KeybindRow( + label: preferences.preferences.replaceKeyLabel, + keyCode: preferences.preferences.replaceKeyCode, + isRecording: $isRecordingShortcut, + onRecord: { keyCode, modifiers, label in + preferences.setReplaceKey(keyCode: keyCode, modifiers: modifiers.rawValue, label: label) + }, + onReset: { + preferences.setReplaceKey( + keyCode: TranslationPreferences.defaultReplaceKeyCode, + modifiers: TranslationPreferences.defaultReplaceKeyModifiers, + label: TranslationPreferences.defaultReplaceKeyLabel + ) + }, + resetLabel: "Reset", + shouldShowReset: preferences.preferences.replaceKeyCode != TranslationPreferences.defaultReplaceKeyCode + || preferences.preferences.replaceKeyModifiers != TranslationPreferences.defaultReplaceKeyModifiers, + onClear: { + preferences.setReplaceKey( + keyCode: TranslationPreferences.defaultReplaceKeyCode, + modifiers: TranslationPreferences.defaultReplaceKeyModifiers, + label: TranslationPreferences.defaultReplaceKeyLabel + ) + }, + clearLabel: "Use Default", + clearHelp: "Go back to \(TranslationPreferences.defaultReplaceKeyLabel).", + conflictChecker: { _, _ in nil } + ) + } label: { + SettingsRowLabel( + title: "Replace Draft Shortcut", + description: "Replaces your draft with the offered translation. It never sends the message.", + systemImage: "keyboard" + ) + } + .disabled(!preferences.preferences.isEnabled || !preferences.preferences.offersReplyTranslation) + } + + Section("Apps") { + ForEach(preferences.preferences.appBundleIdentifiers, id: \.self) { identifier in + LabeledContent { + Button("Remove") { preferences.setApp(identifier, included: false) } + } label: { + HStack(spacing: 10) { + Image(nsImage: Self.icon(for: identifier)) + .resizable() + .frame(width: 20, height: 20) + .accessibilityHidden(true) + Text(Self.appName(for: identifier)) + } + } + } + Button("Add App…") { addApp() } + .settingsItem(.translationApps) + } + + Section("Languages") { + ForEach(Self.statusLanguages, id: \.self) { code in + LabeledContent(Self.name(code)) { + Text(statusLabel(statuses[code])) + .foregroundStyle(statusColor(statuses[code])) + } + } + Text("Apple Translation runs on this Mac for about 20 languages; download missing ones in " + + "System Settings › General › Language & Region › Translation Languages. Other languages, " + + "like Macedonian, use your Open Source model, which is slower and less accurate.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .settingsItem(.translationLanguages) + } + + Section("Privacy") { + Text("Translation reads text from the chat window with Screen Recording, translates it on this Mac, " + + "and keeps translations in memory only. Nothing is saved, logged, or sent to a server. " + + "Password fields and apps where Cotabby is off are never read.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .task(id: preferences.preferences.readingLanguage) { await refreshStatuses() } + } + + private static func icon(for bundleIdentifier: String) -> NSImage { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { NSWorkspace.shared.icon(forFile: $0.path) } ?? NSWorkspace.shared.icon(for: .applicationBundle) + } + + private static func appName(for bundleIdentifier: String) -> String { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) + .map { FileManager.default.displayName(atPath: $0.path).replacingOccurrences(of: ".app", with: "") } + ?? bundleIdentifier + } + + private static let readingLanguageChoices = ["en", "tr", "mk", "de", "fr", "es", "it", "nl", "ru"] + + private static func name(_ code: String) -> String { + Locale.current.localizedString(forLanguageCode: code) ?? code + } + + private func refreshStatuses() async { + var result: [String: TranslationPairAvailability] = [:] + let reading = preferences.preferences.readingLanguage + for code in Self.statusLanguages where !TranslationLanguagePolicy.sameLanguage(code, reading) { + result[code] = await service.availability(from: code, to: reading) + } + statuses = result + } + + private func statusLabel(_ status: TranslationPairAvailability?) -> String { + switch status { + case .ready: return "Apple Translation" + case .needsDownload: return "Download in System Settings" + case .localModel: return "Open Source model" + case .unavailable: return "Not available" + case nil: return "Your language" + } + } + + private func statusColor(_ status: TranslationPairAvailability?) -> Color { + switch status { + case .ready: return .green + case .needsDownload, .localModel: return .orange + case .unavailable: return .red + case nil: return .secondary + } + } + + private func binding( + _ keyPath: KeyPath, + _ setter: @escaping (Value) -> Void + ) -> Binding { + Binding(get: { preferences.preferences[keyPath: keyPath] }, set: setter) + } + + private func addApp() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.prompt = "Add" + guard panel.runModal() == .OK, let url = panel.url, let metadata = ApplicationBundleMetadata(appURL: url) else { return } + preferences.setApp(metadata.bundleIdentifier, included: true) + } +} diff --git a/Cotabby/UI/Settings/SettingsCategory.swift b/Cotabby/UI/Settings/SettingsCategory.swift index a71af600..ba9a524b 100644 --- a/Cotabby/UI/Settings/SettingsCategory.swift +++ b/Cotabby/UI/Settings/SettingsCategory.swift @@ -17,6 +17,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case emoji case writing case context + case translation case engineAndModel case shortcuts case apps @@ -34,6 +35,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "Emoji" case .writing: return "Writing" case .context: return "Context" + case .translation: return "Translation" case .engineAndModel: return "Engine & Model" case .shortcuts: return "Shortcuts" case .apps: return "Apps" @@ -52,6 +54,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "face.smiling" case .writing: return "square.and.pencil" case .context: return "doc.text" + case .translation: return "character.bubble.fill" case .engineAndModel: return "cpu.fill" case .shortcuts: return "keyboard.fill" case .apps: return "app.badge.fill" @@ -72,6 +75,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return .yellow case .writing: return .indigo case .context: return .teal + case .translation: return .blue case .engineAndModel: return .orange case .shortcuts: return .pink case .apps: return .red @@ -90,6 +94,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .emoji: return "The inline emoji picker" case .writing: return "Length, profile, and corrections" case .context: return "What the model can reference" + case .translation: return "Translate messages and replies" case .engineAndModel: return "Choose the engine and models" case .shortcuts: return "Keys that accept suggestions" case .apps: return "Where Cotabby stays quiet" @@ -106,7 +111,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { static let sidebarGroups: [[SettingsCategory]] = [ [.home], [.general, .appearance, .emoji], - [.writing, .context, .engineAndModel], + [.writing, .context, .translation, .engineAndModel], [.shortcuts, .apps, .permissions], [.performance, .about] ] diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index e65917cc..81101cb4 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -28,6 +28,8 @@ struct SettingsContainerView: View { let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void + @ObservedObject var translationPreferences: TranslationPreferencesStore + let translationService: TranslationService let onQuit: () -> Void @AppStorage("cotabbySettingsSelectedCategoryV2") @@ -142,6 +144,8 @@ struct SettingsContainerView: View { ) case .writing: WritingPaneView(suggestionSettings: suggestionSettings) + case .translation: + TranslationPaneView(preferences: translationPreferences, service: translationService) case .context: ContextPaneView(suggestionSettings: suggestionSettings) case .shortcuts: diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..ebe17cfa 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -59,6 +59,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case automaticallyFixTypos // Context case extendedContext + case translationEnabled + case translationApps + case translationLanguages case contextLivePreview // Engine & Model case engine @@ -149,6 +152,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Spelling Dictionaries" case .automaticallyFixTypos: return "Automatically Fix Typos" case .extendedContext: return "Extended Context" + case .translationEnabled: return "Translate Messages" + case .translationApps: return "Translation Apps" + case .translationLanguages: return "Translation Languages" case .contextLivePreview: return "Live Preview" case .engine: return "Engine" case .appleIntelligenceAvailability: return "Apple Intelligence Availability" @@ -233,6 +239,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "character.book.closed" case .automaticallyFixTypos: return "checkmark.circle" case .extendedContext: return "doc.text" + case .translationEnabled: return "character.bubble" + case .translationApps: return "app.badge" + case .translationLanguages: return "globe" case .contextLivePreview: return "text.cursor" case .engine: return "cpu" case .appleIntelligenceAvailability: return "apple.logo" @@ -293,6 +302,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .writing case .extendedContext, .contextLivePreview: return .context + case .translationEnabled, .translationApps, .translationLanguages: + return .translation case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, @@ -360,6 +371,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .spellingDictionaries: return "Dictionaries used to detect typos." case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." case .extendedContext: return "A glossary or notes sent with every suggestion." + case .translationEnabled: return "Translate incoming messages and offer replies in the chat's language." + case .translationApps: return "Apps whose chats are translated." + case .translationLanguages: return "Which languages translate on this Mac, and how." case .contextLivePreview: return "A real field that exercises the full pipeline." case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." @@ -526,6 +540,13 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .automaticallyFixTypos: return ["typo", "automatic", "automatically", "autocorrect", "fix", "spelling", "replace", "space", "instant", "without accepting"] + case .translationEnabled: + return ["translate", "translation", "translator", "augmented", "incoming", "reply", "foreign language", + "turkish", "macedonian", "messages", "chat"] + case .translationApps: + return ["translate", "translation apps", "whatsapp", "teams", "telegram", "slack", "outlook", "mail"] + case .translationLanguages: + return ["translation languages", "apple translation", "download languages", "offline translation"] case .extendedContext: return ["context", "glossary", "reference", "notes", "jargon", "instructions", "memory", "background", "system prompt", "vocabulary"] diff --git a/CotabbyTests/Support/Translation/TranslationCoreTests.swift b/CotabbyTests/Support/Translation/TranslationCoreTests.swift new file mode 100644 index 00000000..93037ed5 --- /dev/null +++ b/CotabbyTests/Support/Translation/TranslationCoreTests.swift @@ -0,0 +1,214 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +final class TranslationLanguagePolicyTests: XCTestCase { + func test_turkishMessageNeedsTranslationIntoEnglish() { + let detection = TranslationLanguagePolicy.needsTranslation( + "Yarın sabah toplantıdan sonra seni arayacağım, tamam mı?", readingLanguage: "en" + ) + XCTAssertEqual(detection?.language, "tr") + } + + func test_englishMessageIsLeftAlone() { + XCTAssertNil(TranslationLanguagePolicy.needsTranslation( + "I will call you tomorrow morning after the meeting.", readingLanguage: "en-US" + )) + } + + func test_macedonianIsRecognizedByItsAlphabet() { + XCTAssertEqual(TranslationLanguagePolicy.detect("Ќе ти се јавам после состанокот утре.")?.language, "mk") + } + + func test_russianIsNotMistakenForMacedonian() { + XCTAssertEqual(TranslationLanguagePolicy.detect("Я позвоню тебе завтра утром после встречи.")?.language, "ru") + } + + func test_shortTextLinksAndNumbersAreSkipped() { + XCTAssertNil(TranslationLanguagePolicy.detect("tamam")) + XCTAssertNil(TranslationLanguagePolicy.detect("https://imperum.io/docs/getting-started")) + XCTAssertNil(TranslationLanguagePolicy.detect("+31 6 1234 5678 / 11:20 / €4.500,00")) + } + + func test_lowConfidenceHypothesesAreRejected() { + XCTAssertNil(TranslationLanguagePolicy.confidentLanguage(from: [("tr", 0.55), ("az", 0.4)])) + XCTAssertEqual(TranslationLanguagePolicy.confidentLanguage(from: [("tr", 0.92)])?.language, "tr") + } + + func test_draftLanguageCheck() { + XCTAssertTrue(TranslationLanguagePolicy.isWritten(in: "en", "Thanks, I will send the report tonight.")) + XCTAssertFalse(TranslationLanguagePolicy.isWritten(in: "en", "Teşekkürler, raporu bu akşam göndereceğim.")) + } +} + +final class TranslationCacheTests: XCTestCase { + private func result(_ text: String) -> TranslationResult { + TranslationResult(sourceText: text, sourceLanguage: "tr", targetLanguage: "en", translatedText: text.uppercased()) + } + + func test_storesAndEvictsLeastRecentlyUsed() { + var cache = TranslationCache(capacity: 2) + cache.store(result("bir")) + cache.store(result("iki")) + _ = cache.value(for: "bir", target: "en") + cache.store(result("üç")) + + XCTAssertNotNil(cache.value(for: "bir", target: "en")) + XCTAssertNil(cache.value(for: "iki", target: "en")) + XCTAssertEqual(cache.count, 2) + } +} + +final class FewShotTranslationPromptTests: XCTestCase { + func test_promptEndsWithTheTargetLabelAndKeepsOneLinePerMessage() throws { + let prompt = try XCTUnwrap(FewShotTranslationPrompt.prompt(for: "Здраво,\nкако си?", source: "mk", target: "en")) + + XCTAssertTrue(prompt.hasSuffix("Macedonian: Здраво, како си?\nEnglish:")) + XCTAssertTrue(prompt.contains("English: I will call you after the meeting.")) + } + + func test_outputStopsAtTheFirstLineOrRepeatedLabel() { + XCTAssertEqual(FewShotTranslationPrompt.cleanedOutput(" Hello, how are you?\n\nMacedonian: ..."), "Hello, how are you?") + XCTAssertEqual(FewShotTranslationPrompt.cleanedOutput(" Hello there. Macedonian: Здраво"), "Hello there.") + } + + func test_unsupportedPairsHaveNoPrompt() { + XCTAssertFalse(FewShotTranslationPrompt.supports(source: "ja", target: "en")) + XCTAssertNil(FewShotTranslationPrompt.prompt(for: "こんにちは", source: "ja", target: "en")) + } +} + +final class MessageBlockGrouperTests: XCTestCase { + private let window = CGRect(x: 100, y: 100, width: 1000, height: 800) + + /// A Vision box for a line at the given window-relative top-left point. + private func line(_ text: String, x: CGFloat, y: CGFloat, width: CGFloat = 400, height: CGFloat = 20, + confidence: Float = 0.95) -> MessageBlockGrouper.Line { + MessageBlockGrouper.Line( + text: text, confidence: confidence, + boundingBox: CGRect(x: x / 1000, y: 1 - (y + height) / 800, width: width / 1000, height: height / 800) + ) + } + + func test_wrappedLinesOfOneMessageMergeAndSeparateMessagesStaySplit() { + let lines = [ + line("Yarın sabah toplantıdan sonra", x: 400, y: 200), + line("seni arayacağım.", x: 400, y: 222), + line("Tamam, bekliyorum o zaman!", x: 400, y: 300) + ] + + let blocks = MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: nil) + + XCTAssertEqual(blocks.map(\.text), ["Yarın sabah toplantıdan sonra seni arayacağım.", "Tamam, bekliyorum o zaman!"]) + XCTAssertEqual(blocks[0].frame.minY, 300, accuracy: 0.5, "Mapped into global points") + } + + func test_chatListLeftOfTheReplyFieldAndTheDraftAreIgnored() { + let compose = CGRect(x: 400, y: 820, width: 600, height: 40) + let lines = [ + line("Ahmet: Merhaba nasılsın bugün", x: 20, y: 200), + line("Merhaba, toplantı saat kaçta?", x: 420, y: 200), + line("draft I am typing right now", x: 320, y: 725) + ] + + let blocks = MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: compose) + + XCTAssertEqual(blocks.map(\.text), ["Merhaba, toplantı saat kaçta?"]) + } + + func test_timestampsAndLowConfidenceLinesAreDropped() { + let lines = [ + line("11:20", x: 400, y: 200, width: 40), + line("Iİ!", x: 400, y: 260, confidence: 0.2), + line("Görüşürüz yarın akşam", x: 400, y: 320) + ] + + XCTAssertEqual(MessageBlockGrouper.blocks(from: lines, windowFrame: window, composeFrame: nil).map(\.text), + ["Görüşürüz yarın akşam"]) + } +} + +final class ConversationLanguageTrackerTests: XCTestCase { + func test_remembersTheLatestLanguagePerConversationAndCaps() { + var tracker = ConversationLanguageTracker(capacity: 2) + let ahmet = ConversationLanguageTracker.key(bundleIdentifier: "net.whatsapp.WhatsApp", windowTitle: "Ahmet") + tracker.record(language: "tr", for: ahmet, at: Date(timeIntervalSince1970: 1)) + tracker.record(language: "mk", for: "b", at: Date(timeIntervalSince1970: 2)) + tracker.record(language: "de", for: "c", at: Date(timeIntervalSince1970: 3)) + + XCTAssertNil(tracker.language(for: ahmet), "The oldest conversation was evicted") + XCTAssertEqual(tracker.language(for: "c"), "de") + } +} + +@MainActor +final class TranslationServiceTests: XCTestCase { + private static var retained: [AnyObject] = [] + + private final class FakeEngine: TranslationEngine { + var status: TranslationPairAvailability + private(set) var calls = 0 + let prefix: String + init(status: TranslationPairAvailability, prefix: String) { self.status = status; self.prefix = prefix } + func availability(from source: String, to target: String) async -> TranslationPairAvailability { status } + func translate(_ text: String, from source: String, to target: String) async throws -> String { + calls += 1 + return "\(prefix):\(text)" + } + } + + private func make(apple: TranslationPairAvailability, local: TranslationPairAvailability) + -> (TranslationService, FakeEngine, FakeEngine) { + let appleEngine = FakeEngine(status: apple, prefix: "apple") + let localEngine = FakeEngine(status: local, prefix: "local") + let service = TranslationService(apple: appleEngine, local: localEngine) + Self.retained.append(contentsOf: [appleEngine, localEngine, service] as [AnyObject]) + return (service, appleEngine, localEngine) + } + + func test_appleIsUsedWhenItHasThePair() async throws { + let (service, apple, _) = make(apple: .ready, local: .localModel) + + let result = try await service.translate("Merhaba dünya", from: "tr", to: "en") + + XCTAssertEqual(result.translatedText, "apple:Merhaba dünya") + _ = try await service.translate("Merhaba dünya", from: "tr", to: "en") + XCTAssertEqual(apple.calls, 1, "The second request comes from the cache") + } + + func test_localModelCoversPairsAppleDoesNotSupport() async throws { + let (service, _, local) = make(apple: .unavailable, local: .localModel) + + let result = try await service.translate("Здраво", from: "mk", to: "en") + + XCTAssertEqual(result.translatedText, "local:Здраво") + XCTAssertEqual(local.calls, 1) + let status = await service.availability(from: "mk", to: "en") + XCTAssertEqual(status, .localModel) + } +} + +@MainActor +final class TranslationPreferencesStoreTests: XCTestCase { + private static var retained: [AnyObject] = [] + + func test_defaultsAreOffWithSuggestedAppsAndPersist() { + let suite = "cotabby.test.translation.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = TranslationPreferencesStore(userDefaults: defaults) + Self.retained.append(store) + XCTAssertFalse(store.preferences.isEnabled) + XCTAssertTrue(store.preferences.appBundleIdentifiers.contains("net.whatsapp.WhatsApp")) + XCTAssertFalse(store.isActive(forBundleIdentifier: "net.whatsapp.WhatsApp"), "Off until enabled") + + store.setEnabled(true) + store.setReadingLanguage("tr") + store.setApp("com.example.Chat", included: true) + + let reloaded = TranslationPreferencesStore(userDefaults: defaults) + Self.retained.append(reloaded) + XCTAssertTrue(reloaded.isActive(forBundleIdentifier: "com.example.Chat")) + XCTAssertEqual(reloaded.preferences.readingLanguage, "tr") + } +}