diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 2655f8e..746316e 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0E72FEA87E7B572ABD524BBA /* AppStateRelativePathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */; }; 100B592B0F8E409C474A00C1 /* GistPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B068438E71AE98EB1FE80 /* GistPublisher.swift */; }; 10B4BC87F21C770AFD3CA8BF /* AppStateSplitPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DA552722A4F7DEA5F6D66F /* AppStateSplitPaneTests.swift */; }; + 15234D80452AA2E7B9DFDCCA /* AIRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */; }; 1544C9F45B0FCC6B21E87F7D /* FileTreeDragDropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8522CDE0F66E2CBBE14548 /* FileTreeDragDropTests.swift */; }; 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */; }; 17E6E2EC1F030DFAA7A1AA48 /* MarkdownPreviewBlockRevealTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4627D80D824902FA0D51573B /* MarkdownPreviewBlockRevealTests.swift */; }; @@ -32,11 +33,13 @@ 237E41D444BB8BFDEFF9BA9E /* MarkdownTaskCheckboxInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899E05E166DE4C34A16EF3A1 /* MarkdownTaskCheckboxInteraction.swift */; }; 23A9905EA035D7984B0D57EC /* AppStateExitVaultFullTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C17DE1D0FB3FA44308006D /* AppStateExitVaultFullTests.swift */; }; 2652C650A15CCC7F082BEFDB /* FontEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B497E7AE9FD12BE22ED2076E /* FontEnumeratorTests.swift */; }; + 28C8E0B75AF2DBB0A967C02E /* AIModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99235052B61D81D983DBA3CC /* AIModelTests.swift */; }; 2913A93CEC3CE4AAE7C0A377 /* EditorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F11197598BEFCA9CE509634 /* EditorState.swift */; }; 2C5DA5CE689E982A146800B0 /* VaultRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46245289C81F5BC71F3DAA8D /* VaultRootResolverTests.swift */; }; 2E123ADFE2CDB5975FB4DAD2 /* MarkdownTaskCheckboxMatchesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16925836C0D8191A7B15229 /* MarkdownTaskCheckboxMatchesTests.swift */; }; 2E4AF87A1309CA7518DDC336 /* VaultRootResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB8CA5F16479103D3A147F2 /* VaultRootResolver.swift */; }; 2E7CDA10ECE1F31071384F86 /* AppStateGitDateFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */; }; + 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C6708A91911EC9011CB953 /* InlineAIController.swift */; }; 3003A94FEAA13E9EFD4FD07F /* TaskListCheckboxInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653C01C03E027EF0D3CBE1B /* TaskListCheckboxInteractionTests.swift */; }; 31E080048A2413F405EAECD4 /* FolderAppearancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFBA8491471536C682215C /* FolderAppearancePicker.swift */; }; 326AC34FA9ED033D152E800D /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE935FC16158D93B5B6C05C /* TabBarView.swift */; }; @@ -52,6 +55,7 @@ 3B0C57EAD1CF3E3BF15B4B54 /* AppStateCloneRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A508264581710F478987F4CA /* AppStateCloneRepositoryTests.swift */; }; 3B3290973348664FF0C25D3B /* AppStateTemplatesDirNormalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45941B55E87CD77FB7F4318F /* AppStateTemplatesDirNormalizationTests.swift */; }; 3B9076F7F7CC149A4CF7EB5C /* AppStateSyncToRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B908F451DF8902DE532C1840 /* AppStateSyncToRemoteTests.swift */; }; + 3BAE5845C73805CD95CDD3C1 /* KeychainStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */; }; 3C12E6F17FBD8619EC3669BC /* EditorModeToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A8A5CC2F996769E0CD694C /* EditorModeToggle.swift */; }; 3CD1E9CA022A6228FB48D629 /* MarkdownPreviewCursorReveal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822CD34D3501462C7E54EE9C /* MarkdownPreviewCursorReveal.swift */; }; 3D85E143721A2CA06CF0867E /* AppStateDateFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C69F6E45C0225C634AA462 /* AppStateDateFilteringTests.swift */; }; @@ -59,6 +63,7 @@ 3EEF1F26FE29D10CEB14D35D /* TagsPaneFiltering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81AA788741433ECCB117F555 /* TagsPaneFiltering.swift */; }; 3F374DE62ACC601002E9784F /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24393FE4F4F016F8BB91C453 /* SearchView.swift */; }; 3F40FA004E127110B79AEBE2 /* InlineTagClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */; }; + 417BBD06E9C053F30E93C6FD /* AnthropicClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */; }; 41EE4BC043D5D570B9EDD698 /* CalendarDayActivityCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB8C6CC66C5265161E6B171 /* CalendarDayActivityCalculatorTests.swift */; }; 438688D812A3F388B1AB5B96 /* VaultIndexNotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537C8698E36941D81BFF3027 /* VaultIndexNotifyTests.swift */; }; 43BD4B04755BAD25794A6A5D /* TagsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9ED59C427BB7415B869FD8 /* TagsPaneView.swift */; }; @@ -85,6 +90,7 @@ 5889163AE1AB27AD826FB7A0 /* AppStateSettingsPropagationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAAF8FF8C6EFD3D4FCA4AB9 /* AppStateSettingsPropagationTests.swift */; }; 58E3A18584997F89E1C8B684 /* SettingsManagerGitHubPATTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71D759FE46432A31DC5812A /* SettingsManagerGitHubPATTests.swift */; }; 59F661C4DCF4A81AAEB36100 /* AppStateRefreshFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4CC6A41F61F91FEC880B6F /* AppStateRefreshFilesTests.swift */; }; + 5B07DDBDCE0FAF1375C70C49 /* InlineAIControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */; }; 5BA2632CF700608CB9207052 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDB03B278DA79A2CEC2DDD3 /* SettingsView.swift */; }; 5BC8CEFC0D52E5C858D7152D /* SplitPaneKeyboardAndCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AEFF34F972283D98C3D9C /* SplitPaneKeyboardAndCursorTests.swift */; }; 5CB28B4EDAA3392E8352C31B /* SlashCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE927C6293929DF30B1321D5 /* SlashCommands.swift */; }; @@ -106,6 +112,7 @@ 747B4579D40DC92E5BC52D15 /* AppStateCloseTabAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6D2B8287A4996E0B938ED4A /* AppStateCloseTabAutoSaveTests.swift */; }; 750735D3C43CA32CB7054567 /* MarkdownTaskCheckboxHitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61F2346098B568FB3D701E1 /* MarkdownTaskCheckboxHitTests.swift */; }; 75868B8981425F687CEDD240 /* VaultIndexRecencyMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F604CE4ECF1D7FF3CA1A636B /* VaultIndexRecencyMirrorTests.swift */; }; + 770EB38B41FEE91D7C6D792C /* AIContextResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */; }; 779344E4C032232DA6038FB2 /* EditorViewPendingStateConsumptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED7FD40634D8A1D8C02441E /* EditorViewPendingStateConsumptionTests.swift */; }; 77AD06E319282914366927F3 /* EmojiFlickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE37BFA970CBBD53D660C05B /* EmojiFlickerTests.swift */; }; 7904B5C075050635D544E7A3 /* CalendarDayActivityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91171FEE1C7C6A243104ABF5 /* CalendarDayActivityCalculator.swift */; }; @@ -133,6 +140,7 @@ 8FC107D698F02354E552B27C /* MarkdownEditorRefreshPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97F141AB461BDBCB144AA23 /* MarkdownEditorRefreshPlanTests.swift */; }; 90D9B07563AEB68B0AA49AD1 /* SynapseNotesThemeLayoutConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A7A1C35AB0F5E61361B8CC /* SynapseNotesThemeLayoutConstantsTests.swift */; }; 91BB84288C2CF108420F3D79 /* GitAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879F41928C2415608DAB7B50 /* GitAutoSaveTests.swift */; }; + 9244B5B92ED33AB0304A1E7F /* AIContextResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */; }; 92486526574CEF72A2328B55 /* SettingsPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53C4F1F9D71478B0FB489B9 /* SettingsPersistenceTests.swift */; }; 9314440DA9709842E4DF1F05 /* NoteLinkRelationshipsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06317C44192BDBD60662AA11 /* NoteLinkRelationshipsTests.swift */; }; 936EBEACAF72C21722F4DE45 /* AppStateNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2788169302A62D1CE3240B96 /* AppStateNavigationTests.swift */; }; @@ -165,6 +173,7 @@ A80DD5DDE7E0B4CD8B0FDA23 /* ThemeEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD21DF8283BD834E74E66901 /* ThemeEnvironmentTests.swift */; }; A895D8B22923A434CB532968 /* SettingsManagerRemovePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA44CD4FEBF5ACD032DB5A41 /* SettingsManagerRemovePaneTests.swift */; }; AAC297094394859DB2403374 /* MarkdownPreviewSemanticHiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */; }; + AC269AD6EEEC8B445F9CC317 /* AIRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */; }; AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */; }; ADE218C972D4E4C39328E2C5 /* SortCriterionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D8A00BEAC46C6928C5AE28 /* SortCriterionTests.swift */; }; AEF05E2787758819B9941B3C /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCACF2EB4506E8D0CECF05B /* SettingsManagerTests.swift */; }; @@ -190,6 +199,7 @@ C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */; }; C8D4635C3B403E5960D306D2 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C730F7180CA55FDC105228A3 /* DatePageView.swift */; }; CC0C3EB6FCED4C362CEB5919 /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86AA4CD7217A37F061FC14C /* AppStateRelatedLinksTests.swift */; }; + CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B54D87558101D4D0356C986 /* InlineAIView.swift */; }; CD9FE8D4C38BFEAED26099A4 /* FlatFolderNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C5541C2A91D24172ABE7B /* FlatFolderNavigatorTests.swift */; }; CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E58E5951953EF520E265CE /* MarkdownDocument.swift */; }; CDEA9B7F724C5CBDFF2AF760 /* SidebarPaneTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9AE1A2BEF83A9F60A76D33 /* SidebarPaneTitleTests.swift */; }; @@ -224,18 +234,22 @@ EB3897F802C8F42954048C02 /* GitErrorHostnameExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB202F7E2555B3DFF17CF27 /* GitErrorHostnameExtractionTests.swift */; }; EDB0B183979559F12CCC41A9 /* GitServiceFileContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB75C4A51AE083BB4A55744 /* GitServiceFileContentTests.swift */; }; EDCCE56A291B55521F4250C3 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F38622C9378AF531F95E05B /* ContentView.swift */; }; + EFB8F0B58A97328C178B78D4 /* AnthropicClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */; }; F228B8C0174B187AF8E91BB3 /* MiniBrowserURLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */; }; F2C9C4B772AEAA1E6CB07B04 /* FolderAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */; }; F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */; }; F4163BF689BE7BC5A40BCB43 /* AppStatePendingSearchQueryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */; }; F422146CA313257EAB4ABEAF /* AppStateWikiLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */; }; F50070470A90436FCBDC6B9B /* AppStateEditModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125FEE59A72FC5BB02643BBC /* AppStateEditModeTests.swift */; }; + F6170085EEDEC0FEFB29B4F8 /* AIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */; }; F661197178F1AD653D16FEE7 /* GitStageChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9597F3D23EECF35FB13CAEED /* GitStageChangesTests.swift */; }; F6CEF5866E01779FB2F5DA02 /* AppStateUntitledNoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF72CADB7EA1F317CA73F7 /* AppStateUntitledNoteTests.swift */; }; F7DEAD2997B96D0BFE19C7F9 /* RelatedLinksTitleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110835FD9934C200B95DBEB3 /* RelatedLinksTitleText.swift */; }; F85C77A43A8701D0AD9143BE /* AppStateTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */; }; F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */; }; F92DE068F0D6C76962CF0898 /* FileBrowserErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE44B10BDADC754E1B880CB /* FileBrowserErrorTests.swift */; }; + FB020284E27C7A52271D56B6 /* SettingsManagerAIModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */; }; + FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77494AFD9121ABE56BF45594 /* KeychainStore.swift */; }; FBDE6F50B234E8BEEC81D70B /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */; }; FD832B5AFBA2E311DEB0EF87 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFDA7432187B97B46732652 /* CommandPaletteScoringTests.swift */; }; FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */; }; @@ -257,6 +271,7 @@ 01F60B6E95850384FC16643E /* RespectGitignoreSettingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RespectGitignoreSettingTests.swift; sourceTree = ""; }; 0311B03851A1F52ACE8F394F /* CollapsibleSectionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSectionsTests.swift; sourceTree = ""; }; 06317C44192BDBD60662AA11 /* NoteLinkRelationshipsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteLinkRelationshipsTests.swift; sourceTree = ""; }; + 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextResolver.swift; sourceTree = ""; }; 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeTests.swift; sourceTree = ""; }; 0A0ACEC175A51BDC23B61E0A /* CommandPaletteWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteWikiLinkTests.swift; sourceTree = ""; }; 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRelativePathTests.swift; sourceTree = ""; }; @@ -320,6 +335,7 @@ 5538485B416CB9D46FF04143 /* SettingsManagerThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerThemeTests.swift; sourceTree = ""; }; 565D75A87A99E7FF0B231E8F /* SynapseNotesThemeConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesThemeConstantsTests.swift; sourceTree = ""; }; 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCalloutStructTests.swift; sourceTree = ""; }; + 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestBuilder.swift; sourceTree = ""; }; 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagStylingTests.swift; sourceTree = ""; }; 5BB75C4A51AE083BB4A55744 /* GitServiceFileContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceFileContentTests.swift; sourceTree = ""; }; 5CCACF2EB4506E8D0CECF05B /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; @@ -328,6 +344,7 @@ 5EEAC9EFE313BC2825BCA71C /* AppStateFolderOperationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFolderOperationsTests.swift; sourceTree = ""; }; 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalGraphView.swift; sourceTree = ""; }; 60493CC60AF2648AEE02AE42 /* CommandPaletteFolderScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteFolderScoringTests.swift; sourceTree = ""; }; + 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestBuilderTests.swift; sourceTree = ""; }; 616A3A93145BC8788AED90B3 /* FolderAppearanceModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearanceModelTests.swift; sourceTree = ""; }; 62195C34AC61C3601DA4E5F9 /* CriticalSidebarAndEditorRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalSidebarAndEditorRoutingTests.swift; sourceTree = ""; }; 621DD480ABAE023F532B8613 /* FolderPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPickerView.swift; sourceTree = ""; }; @@ -339,6 +356,7 @@ 678B0A533C1C1E8C3A597FF5 /* GitServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceTests.swift; sourceTree = ""; }; 6923334E0E98F9AAC62B9FB5 /* GitServiceAskpassTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceAskpassTests.swift; sourceTree = ""; }; 6B45F2701C6F1A6E9FBE3B75 /* SettingsManagerBareExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerBareExtensionsTests.swift; sourceTree = ""; }; + 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModel.swift; sourceTree = ""; }; 6DEF85FC5E955D1C3AB40872 /* NavigationStateActivePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateActivePaneTests.swift; sourceTree = ""; }; 6E9ED59C427BB7415B869FD8 /* TagsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsPaneView.swift; sourceTree = ""; }; 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagClickTests.swift; sourceTree = ""; }; @@ -349,12 +367,14 @@ 71A577424461F3671C9EB9EF /* PinnedItemStructTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemStructTests.swift; sourceTree = ""; }; 7302EBE2E370EAAB51FB25D7 /* CalendarPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarPaneView.swift; sourceTree = ""; }; 733DD6736180FB7287765592 /* NewNoteFolderPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNoteFolderPickerTests.swift; sourceTree = ""; }; + 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicClientTests.swift; sourceTree = ""; }; 73E7A1F6FF2B92F1AECD9D29 /* CloneRepositoryValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidation.swift; sourceTree = ""; }; 749773879FC61AA559EB5F41 /* CodeBlockCopyButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockCopyButtonTests.swift; sourceTree = ""; }; 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewSemanticHiding.swift; sourceTree = ""; }; 75921D668BB1074B9FC3C669 /* AppStateTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTagsTests.swift; sourceTree = ""; }; 7655C25E6F669827478D643B /* AppConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstantsTests.swift; sourceTree = ""; }; 7685D081794D563201BFA13A /* MarkdownEditorInlineSemanticStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorInlineSemanticStylesTests.swift; sourceTree = ""; }; + 77494AFD9121ABE56BF45594 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 7A51CA96771A98688D9AE452 /* HTMLPasteCodeBlockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLPasteCodeBlockTests.swift; sourceTree = ""; }; 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWikiLinkTests.swift; sourceTree = ""; }; 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatePinnedFolderFocusTests.swift; sourceTree = ""; }; @@ -382,6 +402,7 @@ 8DF0C9FE5F40AC32171D4DA8 /* AppStateTemplateVariablesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplateVariablesTests.swift; sourceTree = ""; }; 8EF00AABAF0C69E0006A8999 /* DailyNotesOpenOnStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyNotesOpenOnStartupTests.swift; sourceTree = ""; }; 8F40452ED1B79CCE93DC83ED /* AppStateGitGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitGuardTests.swift; sourceTree = ""; }; + 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerAIModelTests.swift; sourceTree = ""; }; 90830AEF65F3A92E3870CDAE /* AppStateDateTabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateTabTests.swift; sourceTree = ""; }; 91171FEE1C7C6A243104ABF5 /* CalendarDayActivityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculator.swift; sourceTree = ""; }; 9266DDB5C95BD33720E28E45 /* PinnedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItem.swift; sourceTree = ""; }; @@ -391,10 +412,14 @@ 9706C2BF2E3E64D8CD18A2F2 /* GitErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitErrorTests.swift; sourceTree = ""; }; 98763808F3B74804226E6183 /* GistPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherTests.swift; sourceTree = ""; }; 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLToMarkdownTests.swift; sourceTree = ""; }; + 99235052B61D81D983DBA3CC /* AIModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelTests.swift; sourceTree = ""; }; 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceConflictsTests.swift; sourceTree = ""; }; 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTabsTests.swift; sourceTree = ""; }; 9A39EFDD3D11DE137614C99D /* MarkdownDocumentParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentParserTests.swift; sourceTree = ""; }; 9B3B5E5BEF7F68CFB75BE4AC /* MarkdownTablePrettifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTablePrettifierTests.swift; sourceTree = ""; }; + 9B54D87558101D4D0356C986 /* InlineAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIView.swift; sourceTree = ""; }; + 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicClient.swift; sourceTree = ""; }; + 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextResolverTests.swift; sourceTree = ""; }; 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePasteTests.swift; sourceTree = ""; }; 9F3BDA37284748892B9432ED /* ContentCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentCacheTests.swift; sourceTree = ""; }; A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniBrowserURLNormalizer.swift; sourceTree = ""; }; @@ -409,6 +434,7 @@ A6A7A1C35AB0F5E61361B8CC /* SynapseNotesThemeLayoutConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesThemeLayoutConstantsTests.swift; sourceTree = ""; }; A6CBCF06A4567971D9967009 /* SidebarPaneItemCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPaneItemCodableTests.swift; sourceTree = ""; }; A806FC0B1C1C60336C5DE502 /* GitServiceFileDatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceFileDatesTests.swift; sourceTree = ""; }; + A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreTests.swift; sourceTree = ""; }; A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCalloutDetectorTests.swift; sourceTree = ""; }; A97F141AB461BDBCB144AA23 /* MarkdownEditorRefreshPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorRefreshPlanTests.swift; sourceTree = ""; }; A9DD0269C2F999ECBD40462C /* CloneRepositoryValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidationTests.swift; sourceTree = ""; }; @@ -447,6 +473,7 @@ CE33CEA66E67EC988C94FC5E /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; CFE935FC16158D93B5B6C05C /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearance.swift; sourceTree = ""; }; + D0C6708A91911EC9011CB953 /* InlineAIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIController.swift; sourceTree = ""; }; D27CF1236F7775109B48756D /* FileSearchResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResultTests.swift; sourceTree = ""; }; D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitDateFilteringTests.swift; sourceTree = ""; }; D3B6B8AE43A5A53AC28EC7C0 /* EditorFontStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFontStylingTests.swift; sourceTree = ""; }; @@ -466,6 +493,7 @@ E60A5A7B35E4BA0A0789846F /* FileTreeSortingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeSortingTests.swift; sourceTree = ""; }; E712F3D99CEEE8038B89F996 /* RelatedLinksPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksPaneView.swift; sourceTree = ""; }; E75C5E479C5CFAF5D74301AE /* SettingsManagerMovePaneItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerMovePaneItemTests.swift; sourceTree = ""; }; + E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIControllerTests.swift; sourceTree = ""; }; E8BE41491FBC3E8E049191BB /* TerminalBootCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBootCommand.swift; sourceTree = ""; }; E9410A9679D5AF496B9D6F2C /* CodeBlockLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockLayoutTests.swift; sourceTree = ""; }; EA44CD4FEBF5ACD032DB5A41 /* SettingsManagerRemovePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerRemovePaneTests.swift; sourceTree = ""; }; @@ -506,6 +534,10 @@ 0A284EAE36AA06F6499CB6BF /* SynapseNotesTests */ = { isa = PBXGroup; children = ( + 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */, + 99235052B61D81D983DBA3CC /* AIModelTests.swift */, + 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */, + 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */, 7655C25E6F669827478D643B /* AppConstantsTests.swift */, A508264581710F478987F4CA /* AppStateCloneRepositoryTests.swift */, C6D2B8287A4996E0B938ED4A /* AppStateCloseTabAutoSaveTests.swift */, @@ -597,8 +629,10 @@ 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */, 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */, BB6D950DAA29D4324BD4054E /* ImageSidebarEmbedTests.swift */, + E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */, 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */, 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */, + A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */, 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */, 83546378C054CB0E3E4999BE /* ListContinuationTests.swift */, A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */, @@ -631,6 +665,7 @@ 6429DBD855DC174CAA00D16F /* SaveButtonVisibilityTests.swift */, 4A54A8F1A063C6DB71E6A58C /* SearchIndexTests.swift */, 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */, + 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */, 703D69E0596A8F0C009BA65F /* SettingsManagerApplyPaneAssignmentsTests.swift */, 6B45F2701C6F1A6E9FBE3B75 /* SettingsManagerBareExtensionsTests.swift */, 1AEA2BF125EF94AF501B32FC /* SettingsManagerBrowserStartupURLTests.swift */, @@ -689,6 +724,10 @@ F12D5E30E12BF9B949B399CA /* SynapseNotes */ = { isa = PBXGroup; children = ( + 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */, + 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */, + 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */, + 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */, C7E653D29E7497890B389B93 /* AppState.swift */, 7BD9A4F4664E7E7F5F405135 /* AppTheme.swift */, 4DAD62182DBD8964F57045F9 /* Assets.xcassets */, @@ -715,6 +754,9 @@ 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */, B50320B1919639905251EA20 /* GraphPaneView.swift */, 658EA3321387C8DF857E0932 /* Info.plist */, + D0C6708A91911EC9011CB953 /* InlineAIController.swift */, + 9B54D87558101D4D0356C986 /* InlineAIView.swift */, + 77494AFD9121ABE56BF45594 /* KeychainStore.swift */, E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */, 47E58E5951953EF520E265CE /* MarkdownDocument.swift */, 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */, @@ -863,6 +905,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 770EB38B41FEE91D7C6D792C /* AIContextResolver.swift in Sources */, + F6170085EEDEC0FEFB29B4F8 /* AIModel.swift in Sources */, + 15234D80452AA2E7B9DFDCCA /* AIRequestBuilder.swift in Sources */, + 417BBD06E9C053F30E93C6FD /* AnthropicClient.swift in Sources */, D53735E407CC063C6BA0715F /* AppState.swift in Sources */, 1AB0D9338BE326342A1B0129 /* AppTheme.swift in Sources */, 7904B5C075050635D544E7A3 /* CalendarDayActivityCalculator.swift in Sources */, @@ -887,6 +933,9 @@ 0A28812686DE4A1A204E87BC /* GitService.swift in Sources */, 8B03F86871F20001465C62CD /* GlobalGraphView.swift in Sources */, 9E2F95A036FB7A5B399F7795 /* GraphPaneView.swift in Sources */, + 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */, + CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */, + FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */, A6D4D7C24F4ABD2CAA465097 /* MarkdownCallout.swift in Sources */, CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */, 3ADA57036C249201F69BFD3E /* MarkdownEditorInlineSemanticStyles.swift in Sources */, @@ -931,6 +980,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9244B5B92ED33AB0304A1E7F /* AIContextResolverTests.swift in Sources */, + 28C8E0B75AF2DBB0A967C02E /* AIModelTests.swift in Sources */, + AC269AD6EEEC8B445F9CC317 /* AIRequestBuilderTests.swift in Sources */, + EFB8F0B58A97328C178B78D4 /* AnthropicClientTests.swift in Sources */, BFD06E40F78AF2CAF21D04AF /* AppConstantsTests.swift in Sources */, 3B0C57EAD1CF3E3BF15B4B54 /* AppStateCloneRepositoryTests.swift in Sources */, 747B4579D40DC92E5BC52D15 /* AppStateCloseTabAutoSaveTests.swift in Sources */, @@ -1022,9 +1075,11 @@ AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */, 8CA248B41EEFBB966526F952 /* ImagePasteTests.swift in Sources */, 53F221847CDB4F68E669AF75 /* ImageSidebarEmbedTests.swift in Sources */, + 5B07DDBDCE0FAF1375C70C49 /* InlineAIControllerTests.swift in Sources */, 3F40FA004E127110B79AEBE2 /* InlineTagClickTests.swift in Sources */, 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */, 5366B533EBF7FF44B882B007 /* KeyCodeTests.swift in Sources */, + 3BAE5845C73805CD95CDD3C1 /* KeychainStoreTests.swift in Sources */, D2DAC498727FB48FD7B91740 /* ListContinuationTests.swift in Sources */, F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */, C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */, @@ -1056,6 +1111,7 @@ 8C86B3AA977FBBA2C09822B0 /* SaveButtonVisibilityTests.swift in Sources */, D336C7BB900A385C187FCEA5 /* SearchIndexTests.swift in Sources */, F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */, + FB020284E27C7A52271D56B6 /* SettingsManagerAIModelTests.swift in Sources */, C42C30EBF243BBE671B3D4FB /* SettingsManagerApplyPaneAssignmentsTests.swift in Sources */, A63E0A76D7E7C898861060A7 /* SettingsManagerBareExtensionsTests.swift in Sources */, CF02A9597490FF94F3BF089E /* SettingsManagerBrowserStartupURLTests.swift in Sources */, diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift new file mode 100644 index 0000000..68aa951 --- /dev/null +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Resolves `@name` tokens in a prompt into vault context blocks. +/// +/// A token can match a note (by filename stem) or a folder (by folder name). A folder +/// resolves to the concatenated bodies of the notes directly inside it (non-recursive). +/// Pure: file contents and folder listings are read through injected closures. +struct AIContextResolver { + struct Block: Equatable { + let name: String // the file stem or folder name actually matched + let body: String + } + struct Result: Equatable { + var blocks: [Block] + var missing: [String] // @tokens with no matching note or folder + var truncated: Bool + } + + let allFiles: [URL] + let allFolders: [URL] + let charCap: Int + let readContents: (URL) -> String? + /// Direct note children of a folder (non-recursive). Defaults to filtering `allFiles` + /// by parent directory, so callers usually only need to pass `allFiles`/`allFolders`. + let filesInFolder: (URL) -> [URL] + + init( + allFiles: [URL], + allFolders: [URL] = [], + charCap: Int = 100_000, + readContents: @escaping (URL) -> String?, + filesInFolder: ((URL) -> [URL])? = nil + ) { + self.allFiles = allFiles + self.allFolders = allFolders + self.charCap = charCap + self.readContents = readContents + self.filesInFolder = filesInFolder ?? { folder in + allFiles.filter { $0.deletingLastPathComponent().standardizedFileURL == folder.standardizedFileURL } + } + } + + // Matches @[Multi Word Name] (group 1) or a bare @token (group 2). + // The bracket form supports names with spaces; the bare form keeps the common case + // terse. The negative lookbehind skips emails (foo@bar). + private static let tokenRegex = try! NSRegularExpression(pattern: "(? Result { + let ns = prompt as NSString + let matches = Self.tokenRegex.matches(in: prompt, range: NSRange(location: 0, length: ns.length)) + + var seen = Set() + var blocks: [Block] = [] + var missing: [String] = [] + var truncated = false + var used = 0 + + for match in matches { + let bracketRange = match.range(at: 1) + let bareRange = match.range(at: 2) + let token: String + if bracketRange.location != NSNotFound { + token = ns.substring(with: bracketRange) + } else if bareRange.location != NSNotFound { + token = ns.substring(with: bareRange) + } else { + continue + } + let key = token.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + + guard let resolved = resolveToken(key) else { + missing.append(token) + continue + } + + let remaining = charCap - used + if resolved.body.count > remaining { + truncated = true + if remaining > 0 { + blocks.append(Block(name: resolved.name, body: String(resolved.body.prefix(remaining)))) + } + break + } + blocks.append(Block(name: resolved.name, body: resolved.body)) + used += resolved.body.count + } + + return Result(blocks: blocks, missing: missing, truncated: truncated) + } + + /// Resolves a lowercased token to a (display name, body), trying a note first then a + /// folder. Returns nil if nothing matches or the matched content is empty/unreadable. + private func resolveToken(_ key: String) -> (name: String, body: String)? { + // 1) Note by stem. + if let url = allFiles.first(where: { $0.deletingPathExtension().lastPathComponent.lowercased() == key }), + let body = readContents(url) { + return (url.deletingPathExtension().lastPathComponent, body) + } + // 2) Folder by name → concatenate direct-child note bodies (non-recursive). + if let folder = allFolders.first(where: { $0.lastPathComponent.lowercased() == key }) { + let children = filesInFolder(folder).sorted { $0.path < $1.path } + var parts: [String] = [] + for child in children { + if let body = readContents(child), !body.isEmpty { + let stem = child.deletingPathExtension().lastPathComponent + parts.append("## \(stem)\n\(body)") + } + } + guard !parts.isEmpty else { return nil } + return (folder.lastPathComponent, parts.joined(separator: "\n\n")) + } + return nil + } +} diff --git a/macOS/SynapseNotes/AIModel.swift b/macOS/SynapseNotes/AIModel.swift new file mode 100644 index 0000000..6c6ca57 --- /dev/null +++ b/macOS/SynapseNotes/AIModel.swift @@ -0,0 +1,35 @@ +import Foundation + +/// The three Anthropic models the inline AI editor can use. +/// API IDs are the exact Anthropic model strings — no date suffixes. +enum AIModel: String, CaseIterable, Identifiable { + case haiku + case sonnet + case opus + + var id: String { rawValue } + + var apiID: String { + switch self { + case .haiku: return "claude-haiku-4-5" + case .sonnet: return "claude-sonnet-4-6" + case .opus: return "claude-opus-4-8" + } + } + + var displayName: String { + switch self { + case .haiku: return "Haiku 4.5" + case .sonnet: return "Sonnet 4.6" + case .opus: return "Opus 4.8" + } + } + + /// The default model — a balance of speed and quality. + static let `default`: AIModel = .sonnet + + /// Resolve from a stored API ID string, falling back to the default. + init(apiID: String) { + self = AIModel.allCases.first { $0.apiID == apiID } ?? .default + } +} diff --git a/macOS/SynapseNotes/AIRequestBuilder.swift b/macOS/SynapseNotes/AIRequestBuilder.swift new file mode 100644 index 0000000..9c8a5be --- /dev/null +++ b/macOS/SynapseNotes/AIRequestBuilder.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Builds the Anthropic /v1/messages request body for the inline editor. +/// Pure — returns a JSON-serializable dictionary. +enum AIRequestBuilder { + static let maxTokens = 4096 + + static func build( + mode: InlineAIBarMode, + prompt: String, + noteText: String, + selection: String?, + context: [AIContextResolver.Block], + model: AIModel + ) -> [String: Any] { + var user = "" + + if !context.isEmpty { + user += "Reference notes:\n" + for block in context { + user += "--- @\(block.name) ---\n\(block.body)\n\n" + } + } + + user += "Current note:\n\"\"\"\n\(noteText)\n\"\"\"\n\n" + + switch mode { + case .generate: + user += "Task: \(prompt)\n\n" + user += "Write the text to insert at the cursor. Output only the new text, no preamble, no markdown fences." + case .rewrite: + user += "Selected text:\n\"\"\"\n\(selection ?? "")\n\"\"\"\n\n" + user += "Task: \(prompt)\n\n" + user += "Rewrite the selected text accordingly. Output only the replacement text, no preamble, no markdown fences." + } + + let system = """ + You are a writing assistant embedded in a Markdown note editor. \ + You produce text that drops directly into the user's note. \ + Match the surrounding tone and Markdown style. \ + Never wrap your answer in code fences or add commentary — output only the text the user asked for. + """ + + return [ + "model": model.apiID, + "max_tokens": maxTokens, + "stream": true, + "system": system, + "messages": [ + ["role": "user", "content": user] + ] + ] + } +} diff --git a/macOS/SynapseNotes/AnthropicClient.swift b/macOS/SynapseNotes/AnthropicClient.swift new file mode 100644 index 0000000..d143fbf --- /dev/null +++ b/macOS/SynapseNotes/AnthropicClient.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Streams text deltas from the Anthropic /v1/messages SSE endpoint. +/// The URLSession is injectable for testing; defaults to .shared. +struct AnthropicClient { + enum ClientError: Error, Equatable { + case invalidKey + case server(status: Int) + case badResponse + } + + let apiKey: String + var urlSession: URLSession = .shared + + private static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + + /// Streams `text_delta` strings. The async sequence finishes on `message_stop` + /// or end of stream, and throws `ClientError` on a non-2xx status. + func stream(body: [String: Any]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let httpBody: Data + do { + httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + continuation.finish(throwing: error) + return + } + let task = Task { + do { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = httpBody + + let (bytes, response) = try await urlSession.bytes(for: request) + + guard let http = response as? HTTPURLResponse else { + throw ClientError.badResponse + } + guard (200...299).contains(http.statusCode) else { + if http.statusCode == 401 { throw ClientError.invalidKey } + throw ClientError.server(status: http.statusCode) + } + + for try await line in bytes.lines { + try Task.checkCancellation() + guard line.hasPrefix("data:") else { continue } + let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces) + guard !payload.isEmpty, + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if json["type"] as? String == "message_stop" { break } + if json["type"] as? String == "content_block_delta", + let delta = json["delta"] as? [String: Any], + delta["type"] as? String == "text_delta", + let text = delta["text"] as? String { + continuation.yield(text) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 1a43ff9..36d6403 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -738,6 +738,7 @@ struct RawEditor: NSViewRepresentable { func makeNSView(context: Context) -> NSScrollView { let textView = Self.configuredTextView(isEditable: isEditable, settings: appState.settings) + textView.aiAppState = appState textView.delegate = context.coordinator textView.onActivatePane = isEditable ? nil : { appState.focusPane(paneIndex) } @@ -1011,6 +1012,9 @@ struct RawEditor: NSViewRepresentable { tv.applyPreviewStyling(document: document, refreshPlan: refreshPlan, editingSessionOpen: true) } suppressSync = false + // The blanket restyle above wipes transient AI diff colors; restore + // them so they don't flicker between streaming deltas. + tv.reapplyAIDiffColorsIfActive() } func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { @@ -1018,7 +1022,11 @@ struct RawEditor: NSViewRepresentable { guard let tv = textView else { return } let oldText = parent.text let newText = tv.string - if parent.text != newText { + // While a rewrite diff is pending, the buffer holds BOTH the struck-through + // original and the new text. Don't sync that half-diff to the binding (or mark + // dirty / trigger autosave). acceptAI/rejectAI call didChangeText() once resolved, + // which syncs the final text (mode back to .idle, so hasPendingAIDiff is false). + if !tv.hasPendingAIDiff, parent.text != newText { parent.text = newText if let onDidEdit = parent.onDidEdit { onDidEdit() @@ -1059,8 +1067,9 @@ struct RawEditor: NSViewRepresentable { } func textViewDidChangeSelection(_ notification: Notification) { - guard parent.appState.settings.hideMarkdownWhileEditing, - let tv = textView else { return } + guard let tv = textView else { return } + tv.refreshAISparkle() + guard parent.appState.settings.hideMarkdownWhileEditing else { return } // Revealing the raw markdown under the caret is the immediate visual // feedback the user expects, so it runs synchronously on every move. @@ -1495,6 +1504,10 @@ extension LinkAwareTextView { func setPlainText(_ plain: String) { guard let storage = textStorage else { return } + // Tear down any in-flight/pending AI session BEFORE the storage is + // replaced — stale ranges would corrupt the new note or crash on + // accept/reject, and the floating bar would linger over new content. + teardownAISession() // Stale ranges from a previous file would crash reapplySearchHighlights lastSearchHighlightRanges = [] lastSearchFocusIndex = -1 @@ -2023,6 +2036,7 @@ extension LinkAwareTextView { self?.refreshInlineImagePreviews() self?.refreshCollapsibleToggles() self?.refreshCodeBlockCopyButtons() + self?.refreshAISparkle() } } @@ -2242,6 +2256,422 @@ extension LinkAwareTextView { } } + // MARK: - Inline AI editing + + /// Positions a single reused ✨ button just past the end of the caret's line content + /// (or past the selection when text is selected). Anchors to the *used* width of the + /// caret's line fragment, so on an empty line it sits next to the caret rather than + /// at the far right of the text container. Cheap: one layout lookup, no parsing. + func refreshAISparkle() { + guard let layoutManager, let textContainer else { return } + // Respect the user's show/hide preference (default on). + guard settings?.showAISparkle ?? true else { + aiSparkleButton?.isHidden = true + return + } + let sel = selectedRange() + let ns = string as NSString + + // The character index whose line we anchor to: selection end, or the caret. + let anchorIndex = max(0, min(sel.length > 0 ? sel.location + sel.length : sel.location, ns.length)) + + let fallbackLineHeight = layoutManager.defaultLineHeight(for: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) + var lineRect: NSRect + if sel.length > 0 { + // Non-empty selection: anchor just past the trailing edge of its glyphs. + let selGlyphs = layoutManager.glyphRange(forCharacterRange: sel, actualCharacterRange: nil) + lineRect = layoutManager.boundingRect(forGlyphRange: selGlyphs, in: textContainer) + } else if anchorIndex == ns.length + && (ns.length == 0 || ns.substring(with: NSRange(location: ns.length - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil) { + // Caret on the final empty line (empty doc, or after a trailing newline). The + // layout manager tracks this as the "extra line fragment". Its used rect is + // the caret position on that empty line. + let extra = layoutManager.extraLineFragmentUsedRect + if extra.height > 0 { + lineRect = extra + } else { + lineRect = NSRect(x: 0, y: 0, width: 0, height: fallbackLineHeight) + } + } else { + // Caret on a non-trailing (possibly empty) line: use that line fragment's USED + // rect, whose width reflects the actual typeset content (≈ the caret x on an + // empty line), not the full container width — which is what made the ✨ fly + // off to the right. + let glyphIndex = min(layoutManager.glyphIndexForCharacter(at: anchorIndex), max(0, layoutManager.numberOfGlyphs - 1)) + lineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: glyphIndex, effectiveRange: nil) + } + + var rect = lineRect + rect.origin.x += textContainerOrigin.x + rect.origin.y += textContainerOrigin.y + + let size: CGFloat = 18 + let frame = NSRect(x: rect.maxX + 6, y: rect.minY + (rect.height - size) / 2, width: size, height: size) + + let button: AISparkleButton + if let existing = aiSparkleButton { + button = existing + } else { + button = AISparkleButton(frame: frame) + button.target = self + button.action = #selector(aiSparkleTapped) + addSubview(button) + aiSparkleButton = button + } + // This runs on every caret move; avoid needless invalidation when nothing moved. + if button.frame != frame { button.frame = frame } + button.isHidden = (aiBarHostingView != nil) // hide while the bar is open + } + + @objc private func aiSparkleTapped() { + let sel = selectedRange() + let mode: InlineAIBarMode = sel.length > 0 ? .rewrite : .generate + presentAIBar(mode: mode, at: sel) + } + + private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { + dismissAIBar() + aiBarOriginalSelection = sel + aiBarUserMoved = false + aiBarDragStartOrigin = nil + + let defaultModel = AIModel(apiID: settings?.aiDefaultModel ?? AIModel.default.apiID) + let model = InlineAIBarModel(mode: mode, model: defaultModel) + model.allFiles = aiAppState?.allFiles ?? [] + model.allFolders = aiAppState?.allFolders() ?? [] + + model.onSubmit = { [weak self] prompt, chosen in + self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) + } + model.onRetry = { [weak self] prompt, chosen in + // Discard the previous output so the re-run replaces it instead of + // appending, then stream fresh from the original anchor/selection. + self?.inlineAIController.discardOutput() + self?.clearAIDiffColors() + self?.aiBarModel?.awaitingAcceptReject = false + self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) + } + model.onStop = { [weak self] in self?.stopAIStream() } + model.onAccept = { [weak self] in self?.acceptAI() } + model.onReject = { [weak self] in self?.rejectAI() } + model.onCancel = { [weak self] in self?.dismissAIBar() } + model.onDrag = { [weak self] translation in self?.dragAIBar(by: translation) } + model.onDragEnded = { [weak self] in self?.aiBarDragStartOrigin = nil } + model.onContentSizeMayHaveChanged = { [weak self] in self?.resizeAIBarToFit() } + aiBarModel = model + + let host = NSHostingView(rootView: InlineAIBarView(model: model)) + host.frame = aiBarFrame(below: sel) + addSubview(host) + aiBarHostingView = host + refreshAISparkle() + } + + /// Frame for the AI bar. Anchored just below the bottom of the affected region so it + /// never overlaps the streamed text/diff (the region is the end of the streamed + /// `newRange`/`originalRange` once streaming starts, else the selection/cursor). If + /// placing it below would push it past the bottom of the visible viewport (a long + /// diff), it is placed ABOVE the top of the affected region instead, so it stays on + /// screen and still clears the diff. + private func aiBarFrame(below sel: NSRange, size: NSSize? = nil) -> NSRect { + guard let layoutManager, let textContainer else { return .zero } + let ns = string as NSString + let barSize = size ?? aiBarFittedSize() + let width = barSize.width + let barHeight = barSize.height + + func yOffset(forCharacterIndex index: Int) -> (top: CGFloat, bottom: CGFloat) { + let safe = max(0, min(index, ns.length)) + let gr = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) + var r = layoutManager.boundingRect(forGlyphRange: gr, in: textContainer) + r.origin.y += textContainerOrigin.y + return (r.minY, r.maxY) + } + + // Bottom of the affected region (prefer streamed text) and top of it (for the + // above-placement fallback). + var bottomAnchor = sel.length > 0 ? sel.location + sel.length : sel.location + if let nr = inlineAIController.newRange { bottomAnchor = max(bottomAnchor, NSMaxRange(nr)) } + if let orig = inlineAIController.originalRange { bottomAnchor = max(bottomAnchor, NSMaxRange(orig)) } + let topAnchor = min(sel.location, inlineAIController.originalRange?.location ?? sel.location) + + let belowY = yOffset(forCharacterIndex: bottomAnchor).bottom + 6 + let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect + + // If the bar placed below would run past the visible area, place it above the region. + if belowY + barHeight > visible.maxY { + let aboveY = yOffset(forCharacterIndex: topAnchor).top - barHeight - 6 + let clampedY = max(visible.minY + 6, aboveY) + return NSRect(x: 12, y: clampedY, width: width, height: barHeight) + } + return NSRect(x: 12, y: belowY, width: width, height: barHeight) + } + + /// The bar's content-fitted size (drag handle + growing prompt + suggestion list), + /// clamped to the editor width and a sane height range. fittingSize needs the + /// target width set on the host first. + private func aiBarFittedSize() -> NSSize { + let width = min(bounds.width - 24, 520) + var height: CGFloat = 80 + if let host = aiBarHostingView { + host.frame.size.width = width + let fitting = host.fittingSize.height + if fitting > 0 { height = max(60, min(fitting, 360)) } + } + return NSSize(width: width, height: height) + } + + /// Re-anchors the bar below the current affected region (called as text streams in + /// and when streaming finishes) so it tracks the growing diff instead of covering it. + /// No-op once the user has dragged the bar to a manual position. + private func repositionAIBar() { + guard let host = aiBarHostingView, !aiBarUserMoved else { return } + // Streaming doesn't change the bar's content, so reuse its current size — + // avoids a full SwiftUI fitting pass per streamed delta. + host.frame = aiBarFrame(below: aiBarOriginalSelection, size: host.frame.size) + } + + /// Resizes the bar to fit its content (prompt growth, suggestion list). Preserves the + /// user-dragged origin if they moved it; otherwise re-anchors below the affected region. + private func resizeAIBarToFit() { + guard let host = aiBarHostingView else { return } + if aiBarUserMoved { + host.frame.size = aiBarFittedSize() + } else { + host.frame = aiBarFrame(below: aiBarOriginalSelection) + } + } + + /// Moves the bar by a drag-handle translation. The bar is a subview of the text view, + /// which is a flipped NSView (y grows downward) — same direction as SwiftUI's global + /// translation — so the y delta is ADDED, not negated. The translation is cumulative + /// from drag start, so we offset the origin captured when the drag began. + private func dragAIBar(by translation: CGSize) { + guard let host = aiBarHostingView else { return } + aiBarUserMoved = true + let start = aiBarDragStartOrigin ?? host.frame.origin + if aiBarDragStartOrigin == nil { aiBarDragStartOrigin = start } + let newOrigin = NSPoint(x: start.x + translation.width, + y: start.y + translation.height) + // Keep the bar within the visible area. + let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect + let clampedX = min(max(newOrigin.x, visible.minX + 4), visible.maxX - host.frame.width - 4) + let clampedY = min(max(newOrigin.y, visible.minY + 4), visible.maxY - host.frame.height - 4) + host.frame.origin = NSPoint(x: clampedX, y: clampedY) + } + + /// Shared teardown core: cancels any in-flight stream, closes the operation's + /// single undo group, and removes the bar. + private func cancelAIStreamAndRemoveBar() { + aiStreamTask?.cancel(); aiStreamTask = nil + endAIUndoGroup() + aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil + aiBarModel = nil + } + + private func dismissAIBar() { + cancelAIStreamAndRemoveBar() + refreshAISparkle() + } + + /// Tears down any in-flight or pending inline-AI session. Called when the + /// document is swapped (note/tab switch) so stale ranges can't corrupt the + /// new note or crash on accept/reject. Does NOT touch storage: the + /// about-to-run setPlainText replaces the whole attributed string, so old + /// diff colors vanish with it. + func teardownAISession() { + guard aiBarHostingView != nil || inlineAIController.mode != .idle else { return } + cancelAIStreamAndRemoveBar() + inlineAIController.resetWithoutMutating() + } + + /// Re-applies transient AI diff colors after a styling pass, if a session is active. + /// The normal markdown restyle blanket-sets foreground colors, wiping the diff + /// colors; this restores them so they don't flicker mid-stream. + func reapplyAIDiffColorsIfActive() { + guard inlineAIController.mode != .idle else { return } + applyAIDiffColors() + } + + /// Applies an AI text mutation through the standard NSTextView edit path so it + /// registers with the undo manager (Cmd-Z reverts AI insertions/rewrites). Bounds-safe. + /// All edits in one AI operation are coalesced into a single undo group (see + /// `beginAIUndoGroup`/`endAIUndoGroup`) so one Cmd-Z reverts the whole thing. + private func performAIEdit(_ range: NSRange, _ replacement: String) { + guard let storage = textStorage else { return } + guard range.location >= 0, NSMaxRange(range) <= storage.length else { return } + if shouldChangeText(in: range, replacementString: replacement) { + replaceCharacters(in: range, with: replacement) + didChangeText() + } + } + + /// Opens an undo group so every streamed delta + the accept/reject deletion collapse + /// into a single Cmd-Z. Also disables NSTextView's automatic per-keystroke coalescing + /// boundary so the deltas don't split into separate undo steps. + private func beginAIUndoGroup() { + guard !aiUndoGroupOpen else { return } + breakUndoCoalescing() + undoManager?.beginUndoGrouping() + aiUndoGroupOpen = true + } + + /// Closes the AI undo group opened by `beginAIUndoGroup` (idempotent). + private func endAIUndoGroup() { + guard aiUndoGroupOpen else { return } + undoManager?.endUndoGrouping() + breakUndoCoalescing() + aiUndoGroupOpen = false + } + + private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { + guard let storage = textStorage else { return } + guard let key = KeychainStore().get(), !key.isEmpty else { + aiBarModel?.errorMessage = "Add your Anthropic API key in Settings →" + return + } + + // Reuse the vault lists captured when the bar was presented; allFolders() + // walks every file's ancestor chain, so don't recompute it per submit. + let resolver = AIContextResolver( + allFiles: aiBarModel?.allFiles ?? [], + allFolders: aiBarModel?.allFolders ?? [], + readContents: { try? String(contentsOf: $0, encoding: .utf8) }) + let resolved = resolver.resolve(prompt: prompt) + + if mode == .generate { + inlineAIController.beginGenerate(in: storage, at: sel.location) + } else { + inlineAIController.beginRewrite(in: storage, selection: sel) + } + // Route the controller's text mutations through the undo-registering path so the + // whole AI edit is undoable with Cmd-Z (one logical change, not silent storage edits). + inlineAIController.performEdit = { [weak self] range, replacement in + self?.performAIEdit(range, replacement) + } + // Group every edit in this operation (all deltas + the accept/reject deletion) + // into a single undo step. Idempotent, so Retry re-entry keeps the same group. + beginAIUndoGroup() + + let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil + let body = AIRequestBuilder.build( + mode: mode, + prompt: prompt, noteText: string, + selection: selectionText, context: resolved.blocks, model: model) + + aiBarModel?.isStreaming = true + if resolved.truncated { + aiBarModel?.errorMessage = "Context truncated to fit." + } else if !resolved.missing.isEmpty { + aiBarModel?.errorMessage = "\(resolved.missing.count) reference(s) not found." + } else { + aiBarModel?.errorMessage = nil + } + + let client = AnthropicClient(apiKey: key) + aiStreamTask = Task { [weak self] in + do { + for try await delta in client.stream(body: body) { + await MainActor.run { + // appendDelta routes through performAIEdit, which calls didChangeText(). + self?.inlineAIController.appendDelta(delta) + self?.colorAIDelta(appendedLength: (delta as NSString).length) + self?.repositionAIBar() + } + } + await MainActor.run { self?.finishAIStream(mode: mode) } + } catch { + await MainActor.run { self?.handleAIError(error) } + } + } + } + + private func stopAIStream() { + aiStreamTask?.cancel(); aiStreamTask = nil + // finishAIStream owns the per-mode end-of-session rules (generate: reset + + // dismiss; rewrite: await accept/reject). + finishAIStream(mode: aiBarModel?.mode ?? .generate) + } + + private func finishAIStream(mode: InlineAIBarMode) { + aiBarModel?.isStreaming = false + if mode == .rewrite { + aiBarModel?.awaitingAcceptReject = true + } else { + inlineAIController.cancel() + dismissAIBar() + } + applyAIDiffColors() + repositionAIBar() + } + + private func handleAIError(_ error: Error) { + aiBarModel?.isStreaming = false + if let e = error as? AnthropicClient.ClientError { + switch e { + case .invalidKey: aiBarModel?.errorMessage = "Invalid API key — check Settings." + case .server(let s): aiBarModel?.errorMessage = "Server error (\(s)). Try again." + case .badResponse: aiBarModel?.errorMessage = "Unexpected response. Try again." + } + } else { + aiBarModel?.errorMessage = "Network error. Try again." + } + if aiBarModel?.mode == .generate { + inlineAIController.cancel() // generate: reset to idle so a retry starts clean + } + if aiBarModel?.mode == .rewrite { aiBarModel?.awaitingAcceptReject = true } + } + + /// Shared accept/reject epilogue: resolve the diff via the controller, restore + /// normal styling, sync the final text to the binding, and close the bar. + private func resolveAIRewrite(_ resolve: () -> Void) { + resolve() + clearAIDiffColors() + didChangeText() + dismissAIBar() + } + + private func acceptAI() { resolveAIRewrite(inlineAIController.accept) } + + private func rejectAI() { resolveAIRewrite(inlineAIController.reject) } + + /// Colors only the newly appended streamed delta (green) — O(delta) per chunk + /// instead of re-coloring the whole accumulated diff (O(total), quadratic over a + /// stream). The first delta falls back to the full pass so the original range + /// gets its strikethrough/red at the same moment it always has; later wipes by + /// styling passes are restored by `reapplyAIDiffColorsIfActive`. + private func colorAIDelta(appendedLength: Int) { + guard let storage = textStorage, + let nr = inlineAIController.newRange, appendedLength > 0 else { return } + guard nr.length > appendedLength else { + applyAIDiffColors() + return + } + let sub = NSRange(location: NSMaxRange(nr) - appendedLength, length: appendedLength) + guard sub.location >= 0, NSMaxRange(sub) <= storage.length else { return } + storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: sub) + } + + private func applyAIDiffColors() { + guard let storage = textStorage else { return } + if let orig = inlineAIController.originalRange, orig.length > 0, + NSMaxRange(orig) <= storage.length { + storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: orig) + storage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: orig) + } + if let nr = inlineAIController.newRange, nr.length > 0, + NSMaxRange(nr) <= storage.length { + storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: nr) + } + } + + private func clearAIDiffColors() { + guard let storage = textStorage else { return } + let full = NSRange(location: 0, length: storage.length) + storage.removeAttribute(.strikethroughStyle, range: full) + refreshEditorForCurrentDisplayMode(self) + } + @objc private func collapsibleToggleTapped(_ sender: NSControl) { let sectionId = sender.identifier?.rawValue ?? "" guard !sectionId.isEmpty else { return } @@ -2341,6 +2771,27 @@ class LinkAwareTextView: NSTextView { /// Toggle buttons keyed by section identifier ("headerOffset-headerLength") private var collapsibleToggleButtons: [String: CollapsibleToggleButton] = [:] + // MARK: - Inline AI editing + let inlineAIController = InlineAIController() + /// True while a rewrite diff is on screen awaiting accept/reject — the buffer + /// holds both original and new text, which must not be synced/saved as-is. + var hasPendingAIDiff: Bool { inlineAIController.mode == .rewrite } + private var aiSparkleButton: AISparkleButton? + private var aiBarHostingView: NSHostingView? + private var aiBarModel: InlineAIBarModel? + private var aiStreamTask: Task? + /// The selection/cursor captured when the bar opened — used to re-anchor the bar + /// below the affected region as text streams in. + private var aiBarOriginalSelection: NSRange = NSRange(location: 0, length: 0) + /// The bar's origin captured at the start of a drag (nil when not dragging). + private var aiBarDragStartOrigin: NSPoint? + /// Once the user drags the bar, stop auto-repositioning it below the streamed text. + private var aiBarUserMoved = false + /// True while an AI undo group is open (so the whole operation undoes as one step). + private var aiUndoGroupOpen = false + /// Injected at setup; source of vault files for @-context. + weak var aiAppState: AppState? + // MARK: - Embedded Notes (for side panel) private static let embedRegex = try? NSRegularExpression(pattern: #"!\[\[([^\]]+)\]\]"#) @@ -2935,6 +3386,7 @@ class LinkAwareTextView: NSTextView { self?.refreshInlineImagePreviews() self?.refreshCollapsibleToggles() self?.refreshCodeBlockCopyButtons() + self?.refreshAISparkle() } } @@ -3107,6 +3559,13 @@ class LinkAwareTextView: NSTextView { } override func keyDown(with event: NSEvent) { + // ⌥J opens the inline AI bar at the cursor/selection (same as clicking the ✨). + // Works even when the ✨ is hidden via Settings. + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .option, event.charactersIgnoringModifiers?.lowercased() == "j" { + aiSparkleTapped() + return + } if let popover = completionPopover, popover.isShown { switch event.keyCode { case KeyCode.downArrow: completionVC?.moveSelection(by: 1); return diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift new file mode 100644 index 0000000..6d57d94 --- /dev/null +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -0,0 +1,126 @@ +import AppKit +import Combine + +/// Orchestrates an inline AI editing session against an NSTextStorage. +/// +/// Generate mode: streamed deltas are inserted at the cursor (plain text). +/// Rewrite mode: the original selection is kept; new text streams in after it. +/// On `accept`, the original is deleted and the new text remains. On `reject`, +/// the new text is removed and the original stays. Diff *coloring* is applied +/// by the view layer via the published `originalRange`/`newRange`; this controller +/// owns only the text mutations so the logic stays unit-testable. +final class InlineAIController: ObservableObject { + enum Mode: Equatable { case idle, generate, rewrite } + + @Published private(set) var mode: Mode = .idle + /// Range of the original (struck-through) text during a rewrite; nil otherwise. + @Published private(set) var originalRange: NSRange? + /// Range of the streamed new text (generate: the inserted text; rewrite: the green text). + @Published private(set) var newRange: NSRange? + + private weak var storage: NSTextStorage? + + /// Optional edit hook supplied by the view layer. When set, all text mutations are + /// routed through it (so the host can register undo via shouldChangeText/didChangeText); + /// when nil, mutations apply directly to the storage (used by unit tests). The closure + /// receives the range to replace and the replacement string. + var performEdit: ((NSRange, String) -> Void)? + + /// Applies a replacement either through the host's undo-registering hook or directly. + private func applyEdit(_ range: NSRange, _ replacement: String) { + if let performEdit { + performEdit(range, replacement) + } else { + storage?.replaceCharacters(in: range, with: replacement) + } + } + + // MARK: Generate + + func beginGenerate(in storage: NSTextStorage, at location: Int) { + guard mode == .idle else { return } + self.storage = storage + mode = .generate + originalRange = nil + newRange = NSRange(location: location, length: 0) + } + + // MARK: Rewrite + + func beginRewrite(in storage: NSTextStorage, selection: NSRange) { + guard mode == .idle else { return } + self.storage = storage + mode = .rewrite + originalRange = selection + // New text starts immediately after the original selection. + newRange = NSRange(location: selection.location + selection.length, length: 0) + } + + // MARK: Streaming + + /// Appends a streamed text delta at the end of the current `newRange`. + func appendDelta(_ text: String) { + guard storage != nil, var nr = newRange, mode != .idle else { return } + let insertAt = nr.location + nr.length + applyEdit(NSRange(location: insertAt, length: 0), text) + nr.length += (text as NSString).length + newRange = nr + } + + /// Stops streaming. Generate finishes immediately (nothing to accept); + /// rewrite remains pending so the user can accept/reject the partial. + func cancel() { + if mode == .generate { resetWithoutMutating() } + } + + // MARK: Resolution + + /// Rewrite accept: delete the original, keep the new text. No-op in any other mode. + func accept() { + guard mode == .rewrite else { return } + guard let orig = originalRange else { + // Defensive: rewrite mode but no range — clear and bail. + resetWithoutMutating() + return + } + // The new text sits immediately after the original; deleting the original + // shifts the new text left into the original's place. + applyEdit(orig, "") + resetWithoutMutating() + } + + /// Rewrite reject: delete the streamed new text, restore the original. No-op in any other mode. + func reject() { + guard mode == .rewrite else { return } + guard let nr = newRange else { + resetWithoutMutating() + return + } + applyEdit(nr, "") + resetWithoutMutating() + } + + /// Clears all session state WITHOUT mutating the text storage — the common + /// final step of every session end. Also safe when the underlying document + /// is being replaced wholesale (note/tab switch), where touching the old + /// ranges would corrupt the new document or crash. + func resetWithoutMutating() { + mode = .idle + originalRange = nil + newRange = nil + } + + /// Removes the streamed output and returns to idle, leaving the document as it + /// was *before* this session's generation. In generate mode this deletes the + /// inserted text; in rewrite mode it deletes the new text and keeps the original + /// (same end state as `reject()`). Used by Retry so a re-run starts clean instead + /// of appending onto the previous output. + func discardOutput() { + guard mode != .idle else { return } + if let nr = newRange, nr.length > 0, + (storage == nil || NSMaxRange(nr) <= storage!.length) { + applyEdit(nr, "") + } + resetWithoutMutating() + } +} diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift new file mode 100644 index 0000000..001f282 --- /dev/null +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -0,0 +1,302 @@ +import SwiftUI +import AppKit + +/// The clickable ✨ overlay placed at the active line's end or past a selection. +/// Mirrors the editor's existing NSControl-based overlay buttons (target/action). +final class AISparkleButton: NSControl { + /// Resting transparency; goes opaque on hover for a subtle affordance. + private static let restingAlpha: CGFloat = 0.5 + private var trackingArea: NSTrackingArea? + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + toolTip = "Ask AI (⌥J)" + focusRingType = .none + alphaValue = Self.restingAlpha + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func draw(_ dirtyRect: NSRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12) + ] + let s = NSAttributedString(string: "✨", attributes: attrs) + let size = s.size() + s.draw(at: NSPoint(x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2)) + } + + override func mouseDown(with event: NSEvent) { + sendAction(action, to: target) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + // Brighten on hover, dim back when the mouse leaves. + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { removeTrackingArea(trackingArea) } + let area = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { animateAlpha(to: 1.0) } + override func mouseExited(with event: NSEvent) { animateAlpha(to: Self.restingAlpha) } + + private func animateAlpha(to value: CGFloat) { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + animator().alphaValue = value + } + } +} + +/// Whether the AI session generates at the cursor or rewrites a selection. +/// Shared by the bar UI and `AIRequestBuilder`. +enum InlineAIBarMode { case generate, rewrite } + +/// An @-autocomplete suggestion: a vault note or a folder. +struct AISuggestion: Identifiable, Equatable { + enum Kind { case file, folder } + let name: String // stem (file) or folder name + let kind: Kind + var id: String { "\(kind == .folder ? "dir:" : "file:")\(name)" } + var systemImage: String { kind == .folder ? "folder" : "doc.text" } +} + +/// View model backing the inline AI bar. +final class InlineAIBarModel: ObservableObject { + @Published var prompt: String = "" + @Published var model: AIModel + @Published var isStreaming: Bool = false + @Published var errorMessage: String? + @Published var awaitingAcceptReject: Bool = false // rewrite finished, awaiting decision + @Published var atSuggestions: [AISuggestion] = [] // notes + folders matching the active @token + + let mode: InlineAIBarMode + /// Vault note file URLs, for @-autocomplete scoring. + var allFiles: [URL] = [] + /// Vault folder URLs, for @-autocomplete scoring. + var allFolders: [URL] = [] + + // Callbacks wired by the host (Task 9). + var onSubmit: ((String, AIModel) -> Void)? + var onRetry: ((String, AIModel) -> Void)? // re-run, replacing the previous output + var onStop: (() -> Void)? + var onAccept: (() -> Void)? + var onReject: (() -> Void)? + var onCancel: (() -> Void)? // Esc with nothing pending → close the bar + var onDrag: ((CGSize) -> Void)? // drag-handle translation (global coords) + var onDragEnded: (() -> Void)? + var onContentSizeMayHaveChanged: (() -> Void)? // prompt grew / suggestions toggled + + init(mode: InlineAIBarMode, model: AIModel) { + self.mode = mode + self.model = model + } + + /// Recompute @-autocomplete suggestions for the current prompt — notes and folders, + /// scored by the same algorithm the wiki-link autocomplete uses. + func updateSuggestions() { + guard let token = activeAtToken(in: prompt), !token.isEmpty else { + atSuggestions = [] + return + } + let fileScored: [(AISuggestion, Int)] = allFiles.compactMap { + let score = commandPaletteScoreByFilename(forURL: $0, needle: token) + guard score > 0 else { return nil } + return (AISuggestion(name: $0.deletingPathExtension().lastPathComponent, kind: .file), score) + } + let folderScored: [(AISuggestion, Int)] = allFolders.compactMap { + let score = commandPaletteScoreByFolderName(forURL: $0, needle: token) + guard score > 0 else { return nil } + return (AISuggestion(name: $0.lastPathComponent, kind: .folder), score) + } + var seenIDs = Set() + atSuggestions = (fileScored + folderScored) + .sorted { $0.1 > $1.1 } + .map { $0.0 } + .filter { seenIDs.insert($0.id).inserted } // de-dup by id, keep highest score + .prefix(8) + .map { $0 } + } + + /// Extracts the in-progress @token at the end of the prompt, if any. + /// Supports a bracket form `@[Multi Word` (still being typed) so folder/note names + /// with spaces can be filtered as the user types inside the brackets. + private func activeAtToken(in text: String) -> String? { + guard let atIndex = text.lastIndex(of: "@") else { return nil } + var after = Substring(text[text.index(after: atIndex)...]) + if after.first == "[" { + after = after.dropFirst() + // A closed bracket means the token is complete — no live suggestions. + if after.contains("]") { return nil } + return String(after) // may contain spaces — that's the point + } + // Bare token: no spaces. + if after.contains(" ") { return nil } + return String(after) + } + + /// Replace the active @token with the chosen suggestion (bracketed if it has spaces). + func applySuggestion(_ suggestion: AISuggestion) { + guard let atIndex = prompt.lastIndex(of: "@") else { return } + let name = suggestion.name + let token = name.contains(" ") ? "@[\(name)] " : "@\(name) " + prompt = String(prompt[.. String? + func set(_ value: String) + func delete() +} + +/// Securely stores a single secret (the Anthropic API key) in the macOS Keychain. +/// One instance == one (service, account) slot. +struct KeychainStore: SecretStore { + let service: String + let account: String + + init(service: String = "com.SynapseNotes.anthropic", account: String = "apiKey") { + self.service = service + self.account = account + } + + /// The identifying attributes shared by every operation. + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } + + /// Returns the stored secret, or nil if none is set. + func get() -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let string = String(data: data, encoding: .utf8), + !string.isEmpty else { + return nil + } + return string + } + + /// Stores the secret, overwriting any existing value. An empty string deletes the item. + func set(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { delete(); return } + + let data = Data(trimmed.utf8) + let attributes: [String: Any] = [kSecValueData as String: data] + + let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = baseQuery + add[kSecValueData as String] = data + SecItemAdd(add as CFDictionary, nil) + } + } + + /// Removes the stored secret if present. + func delete() { + SecItemDelete(baseQuery as CFDictionary) + } +} + +/// An in-memory `SecretStore` for tests and previews — never touches the system keychain. +final class InMemorySecretStore: SecretStore { + private var value: String? + init(_ initial: String? = nil) { self.value = initial } + + func get() -> String? { (value?.isEmpty == false) ? value : nil } + + func set(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + self.value = trimmed.isEmpty ? nil : trimmed + } + + func delete() { value = nil } +} diff --git a/macOS/SynapseNotes/SettingsManager.swift b/macOS/SynapseNotes/SettingsManager.swift index 9c9d5bf..4ceb1a0 100644 --- a/macOS/SynapseNotes/SettingsManager.swift +++ b/macOS/SynapseNotes/SettingsManager.swift @@ -274,6 +274,10 @@ class SettingsManager: ObservableObject { @Published var githubPAT: String { didSet { save() } } + /// Default Anthropic model (API ID) for inline AI editing (machine-local). + @Published var aiDefaultModel: String { + didSet { save() } + } @Published var fileTreeMode: FileTreeMode { didSet { save() } } @@ -286,6 +290,10 @@ class SettingsManager: ObservableObject { @Published var hideMarkdownWhileEditing: Bool { didSet { save() } } + /// Whether the inline AI ✨ affordance is shown at the cursor/selection. + @Published var showAISparkle: Bool { + didSet { save() } + } @Published var browserStartupURL: String { didSet { save() } } @@ -514,10 +522,12 @@ class SettingsManager: ObservableObject { /// Pane assignments: maps sidebar UUID string -> [SidebarPane] var sidebarPaneAssignments: [String: [SidebarPaneItem]]? var githubPAT: String? + var aiDefaultModel: String? var fileTreeMode: String? var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -544,10 +554,12 @@ class SettingsManager: ObservableObject { collapsedSidebarIDs = try container.decodeIfPresent([String].self, forKey: .collapsedSidebarIDs) sidebarPaneAssignments = try container.decodeIfPresent([String: [SidebarPaneItem]].self, forKey: .sidebarPaneAssignments) githubPAT = try container.decodeIfPresent(String.self, forKey: .githubPAT) + aiDefaultModel = try container.decodeIfPresent(String.self, forKey: .aiDefaultModel) fileTreeMode = try container.decodeIfPresent(String.self, forKey: .fileTreeMode) pinnedItems = try container.decodeIfPresent([PinnedItem].self, forKey: .pinnedItems) defaultEditMode = try container.decodeIfPresent(Bool.self, forKey: .defaultEditMode) hideMarkdownWhileEditing = try container.decodeIfPresent(Bool.self, forKey: .hideMarkdownWhileEditing) + showAISparkle = try container.decodeIfPresent(Bool.self, forKey: .showAISparkle) browserStartupURL = try container.decodeIfPresent(String.self, forKey: .browserStartupURL) editorBodyFontFamily = try container.decodeIfPresent(String.self, forKey: .editorBodyFontFamily) editorMonospaceFontFamily = try container.decodeIfPresent(String.self, forKey: .editorMonospaceFontFamily) @@ -574,6 +586,7 @@ class SettingsManager: ObservableObject { var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -600,6 +613,7 @@ class SettingsManager: ObservableObject { pinnedItems: [PinnedItem]?, defaultEditMode: Bool?, hideMarkdownWhileEditing: Bool?, + showAISparkle: Bool?, browserStartupURL: String?, editorBodyFontFamily: String? = nil, editorMonospaceFontFamily: String? = nil, @@ -625,6 +639,7 @@ class SettingsManager: ObservableObject { self.pinnedItems = pinnedItems self.defaultEditMode = defaultEditMode self.hideMarkdownWhileEditing = hideMarkdownWhileEditing + self.showAISparkle = showAISparkle self.browserStartupURL = browserStartupURL self.editorBodyFontFamily = editorBodyFontFamily self.editorMonospaceFontFamily = editorMonospaceFontFamily @@ -653,6 +668,7 @@ class SettingsManager: ObservableObject { pinnedItems = try container.decodeIfPresent([PinnedItem].self, forKey: .pinnedItems) defaultEditMode = try container.decodeIfPresent(Bool.self, forKey: .defaultEditMode) hideMarkdownWhileEditing = try container.decodeIfPresent(Bool.self, forKey: .hideMarkdownWhileEditing) + showAISparkle = try container.decodeIfPresent(Bool.self, forKey: .showAISparkle) browserStartupURL = try container.decodeIfPresent(String.self, forKey: .browserStartupURL) editorBodyFontFamily = try container.decodeIfPresent(String.self, forKey: .editorBodyFontFamily) editorMonospaceFontFamily = try container.decodeIfPresent(String.self, forKey: .editorMonospaceFontFamily) @@ -668,6 +684,7 @@ class SettingsManager: ObservableObject { /// Config for machine-local settings only private struct GlobalConfig: Codable { var githubPAT: String? + var aiDefaultModel: String? var sidebarPaneHeights: [String: CGFloat]? var collapsedPanes: [String]? var collapsedSidebarIDs: [String]? @@ -682,6 +699,7 @@ class SettingsManager: ObservableObject { init( githubPAT: String?, + aiDefaultModel: String? = nil, sidebarPaneHeights: [String: CGFloat]?, collapsedPanes: [String]?, collapsedSidebarIDs: [String]?, @@ -691,6 +709,7 @@ class SettingsManager: ObservableObject { lastNoteFolderPerVault: [String: String]? = nil ) { self.githubPAT = githubPAT + self.aiDefaultModel = aiDefaultModel self.sidebarPaneHeights = sidebarPaneHeights self.collapsedPanes = collapsedPanes self.collapsedSidebarIDs = collapsedSidebarIDs @@ -704,6 +723,7 @@ class SettingsManager: ObservableObject { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) githubPAT = try container.decodeIfPresent(String.self, forKey: .githubPAT) + aiDefaultModel = try container.decodeIfPresent(String.self, forKey: .aiDefaultModel) sidebarPaneHeights = try container.decodeIfPresent([String: CGFloat].self, forKey: .sidebarPaneHeights) collapsedPanes = try container.decodeIfPresent([String].self, forKey: .collapsedPanes) collapsedSidebarIDs = try container.decodeIfPresent([String].self, forKey: .collapsedSidebarIDs) @@ -746,10 +766,12 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" + self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true self.hideMarkdownWhileEditing = false + self.showAISparkle = true self.browserStartupURL = "" self.editorBodyFontFamily = "System" self.editorMonospaceFontFamily = "System Monospace" @@ -804,10 +826,12 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" + self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true self.hideMarkdownWhileEditing = false + self.showAISparkle = true self.browserStartupURL = "" self.editorBodyFontFamily = "System" self.editorMonospaceFontFamily = "System Monospace" @@ -874,10 +898,12 @@ class SettingsManager: ObservableObject { collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] } githubPAT = config.githubPAT ?? "" + aiDefaultModel = config.aiDefaultModel ?? AIModel.default.apiID fileTreeMode = FileTreeMode(rawValue: config.fileTreeMode ?? "") ?? .folder pinnedItems = config.pinnedItems ?? [] defaultEditMode = config.defaultEditMode ?? true hideMarkdownWhileEditing = config.hideMarkdownWhileEditing ?? false + showAISparkle = config.showAISparkle ?? true browserStartupURL = config.browserStartupURL ?? "" editorBodyFontFamily = config.editorBodyFontFamily ?? "System" editorMonospaceFontFamily = config.editorMonospaceFontFamily ?? "System Monospace" @@ -904,10 +930,12 @@ class SettingsManager: ObservableObject { collapsedPanes = [] collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] githubPAT = "" + aiDefaultModel = AIModel.default.apiID fileTreeMode = .folder pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -942,6 +970,7 @@ class SettingsManager: ObservableObject { pinnedItems = vaultConfig.pinnedItems ?? [] defaultEditMode = vaultConfig.defaultEditMode ?? true hideMarkdownWhileEditing = vaultConfig.hideMarkdownWhileEditing ?? false + showAISparkle = vaultConfig.showAISparkle ?? true browserStartupURL = vaultConfig.browserStartupURL ?? "" editorBodyFontFamily = vaultConfig.editorBodyFontFamily ?? "System" editorMonospaceFontFamily = vaultConfig.editorMonospaceFontFamily ?? "System Monospace" @@ -968,6 +997,7 @@ class SettingsManager: ObservableObject { pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -994,6 +1024,7 @@ class SettingsManager: ObservableObject { pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -1005,6 +1036,7 @@ class SettingsManager: ObservableObject { private func applyGlobalConfig(_ globalConfig: GlobalConfig?) { githubPAT = globalConfig?.githubPAT ?? "" + aiDefaultModel = globalConfig?.aiDefaultModel ?? AIModel.default.apiID sidebars = Self.applyPaneAssignments(globalConfig?.sidebarPaneAssignments) sidebarPaneHeights = globalConfig?.sidebarPaneHeights ?? Self.defaultPaneHeights collapsedPanes = Set(globalConfig?.collapsedPanes ?? []) @@ -1184,10 +1216,12 @@ class SettingsManager: ObservableObject { let collapsedPanes: [String] let collapsedSidebarIDs: [String] let githubPAT: String + let aiDefaultModel: String let fileTreeMode: FileTreeMode let pinnedItems: [PinnedItem] let defaultEditMode: Bool let hideMarkdownWhileEditing: Bool + let showAISparkle: Bool let browserStartupURL: String let editorBodyFontFamily: String let editorMonospaceFontFamily: String @@ -1222,10 +1256,12 @@ class SettingsManager: ObservableObject { collapsedPanes = Array(s.collapsedPanes) collapsedSidebarIDs = Array(s.collapsedSidebarIDs) githubPAT = s.githubPAT + aiDefaultModel = s.aiDefaultModel fileTreeMode = s.fileTreeMode pinnedItems = s.pinnedItems defaultEditMode = s.defaultEditMode hideMarkdownWhileEditing = s.hideMarkdownWhileEditing + showAISparkle = s.showAISparkle browserStartupURL = s.browserStartupURL editorBodyFontFamily = s.editorBodyFontFamily editorMonospaceFontFamily = s.editorMonospaceFontFamily @@ -1257,6 +1293,7 @@ class SettingsManager: ObservableObject { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, @@ -1291,10 +1328,12 @@ class SettingsManager: ObservableObject { var collapsedPanes: [String]? var collapsedSidebarIDs: [String]? var githubPAT: String? + var aiDefaultModel: String? var fileTreeMode: String? var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -1320,10 +1359,12 @@ class SettingsManager: ObservableObject { collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel.isEmpty ? nil : aiDefaultModel, fileTreeMode: fileTreeMode.rawValue, pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, defaultEditMode: defaultEditMode, hideMarkdownWhileEditing: hideMarkdownWhileEditing ? true : nil, + showAISparkle: showAISparkle ? nil : false, browserStartupURL: browserStartupURL.isEmpty ? nil : browserStartupURL, editorBodyFontFamily: editorBodyFontFamily == "System" ? nil : editorBodyFontFamily, editorMonospaceFontFamily: editorMonospaceFontFamily == "System Monospace" ? nil : editorMonospaceFontFamily, @@ -1357,6 +1398,7 @@ class SettingsManager: ObservableObject { pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, defaultEditMode: defaultEditMode, hideMarkdownWhileEditing: hideMarkdownWhileEditing ? true : nil, + showAISparkle: showAISparkle ? nil : false, browserStartupURL: browserStartupURL.isEmpty ? nil : browserStartupURL, editorBodyFontFamily: editorBodyFontFamily == "System" ? nil : editorBodyFontFamily, editorMonospaceFontFamily: editorMonospaceFontFamily == "System Monospace" ? nil : editorMonospaceFontFamily, @@ -1376,6 +1418,7 @@ class SettingsManager: ObservableObject { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, diff --git a/macOS/SynapseNotes/SettingsView.swift b/macOS/SynapseNotes/SettingsView.swift index 7caf4d3..0228c0a 100644 --- a/macOS/SynapseNotes/SettingsView.swift +++ b/macOS/SynapseNotes/SettingsView.swift @@ -21,6 +21,7 @@ struct SettingsView: View { @State private var templateVarsExpanded = false @State private var themeImportError: String? @State private var showThemeImportError = false + @State private var anthropicKey: String = KeychainStore().get() ?? "" private let settingsFieldWidth: CGFloat = 440 @@ -671,6 +672,58 @@ struct SettingsView: View { Text("GitHub Gist") .font(.system(size: 13, weight: .semibold, design: .rounded)) } + + // MARK: - AI Section + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Anthropic API Key") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + + SecureField("sk-ant-...", text: $anthropicKey) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .onChange(of: anthropicKey) { newValue in + KeychainStore().set(newValue) + } + + Text("Stored securely in your macOS Keychain. Used for inline AI editing (✨).") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text("Default Model") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Picker("Default Model", selection: Binding( + get: { AIModel(apiID: settings.aiDefaultModel) }, + set: { settings.aiDefaultModel = $0.apiID } + )) { + ForEach(AIModel.allCases) { model in + Text(model.displayName).tag(model) + } + } + .labelsHidden() + .pickerStyle(.segmented) + + Divider() + + Toggle(isOn: $settings.showAISparkle) { + Text("Show the ✨ button at the cursor") + .font(.system(size: 12, weight: .medium, design: .rounded)) + } + Text("When off, open inline AI editing with ⌥J instead.") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } header: { + Text("AI") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } } .onChange(of: settings.editorBodyFontFamily) { _ in refreshEditorsForFontChange() diff --git a/macOS/SynapseNotesTests/AIContextResolverTests.swift b/macOS/SynapseNotesTests/AIContextResolverTests.swift new file mode 100644 index 0000000..9b8930d --- /dev/null +++ b/macOS/SynapseNotesTests/AIContextResolverTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import Synapse + +final class AIContextResolverTests: XCTestCase { + // Build a resolver whose "files" are in-memory: name -> body. + private func makeResolver(_ files: [String: String], cap: Int = 100_000) -> AIContextResolver { + let urls = files.keys.map { URL(fileURLWithPath: "/vault/\($0).md") } + return AIContextResolver( + allFiles: urls, + charCap: cap, + readContents: { url in files[url.deletingPathExtension().lastPathComponent] } + ) + } + + func test_noAtTokens_returnsEmptyContextNoMissing() { + let r = makeResolver(["Foo": "body"]) + let result = r.resolve(prompt: "summarize the note") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertFalse(result.truncated) + } + + func test_resolvesSingleAtToken_caseInsensitive() { + let r = makeResolver(["Daily": "daily body"]) + let result = r.resolve(prompt: "use @daily please") + XCTAssertEqual(result.blocks.count, 1) + XCTAssertEqual(result.blocks[0].name, "Daily") + XCTAssertEqual(result.blocks[0].body, "daily body") + XCTAssertTrue(result.missing.isEmpty) + } + + func test_missingRef_isReportedAndSkipped() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@nope and @Foo") + XCTAssertEqual(result.blocks.map(\.name), ["Foo"]) + XCTAssertEqual(result.missing, ["nope"]) + } + + func test_overCap_truncatesAndFlags() { + let big = String(repeating: "a", count: 60_000) + let r = makeResolver(["One": big, "Two": big], cap: 100_000) + let result = r.resolve(prompt: "@One @Two") + XCTAssertTrue(result.truncated) + let total = result.blocks.reduce(0) { $0 + $1.body.count } + XCTAssertLessThanOrEqual(total, 100_000) + } + + func test_duplicateRefs_resolvedOnce() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@Foo and again @foo") + XCTAssertEqual(result.blocks.count, 1) + } + + func test_emptyPrompt_returnsEmptyResult() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertFalse(result.truncated) + } + + func test_trailingPunctuation_doesNotBreakMatch() { + let r = makeResolver(["Budget": "budget body"]) + // "@Budget." at a sentence end must still resolve to "Budget". + let result = r.resolve(prompt: "see @Budget.") + XCTAssertEqual(result.blocks.map(\.name), ["Budget"]) + XCTAssertTrue(result.missing.isEmpty) + } + + func test_emailAddress_isNotTreatedAsAtToken() { + let r = makeResolver(["Bar": "bar body"]) + // "foo@bar.com" is an email — the @ is preceded by a word char, so no token. + let result = r.resolve(prompt: "reply to foo@bar.com please") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + } + + func test_exactCapBoundary_keepsFirstBlockOnly() { + let exact = String(repeating: "a", count: 100_000) + let r = makeResolver(["One": exact, "Two": "more"], cap: 100_000) + let result = r.resolve(prompt: "@One @Two") + XCTAssertEqual(result.blocks.map(\.name), ["One"]) + // The whole first block fits exactly; the second is dropped by the cap. + XCTAssertEqual(result.blocks[0].body.count, 100_000) + } + + func test_bracketedToken_withSpaces_resolves() { + let r = makeResolver(["My Note": "spaced body"]) + let result = r.resolve(prompt: "see @[My Note] please") + XCTAssertEqual(result.blocks.map(\.name), ["My Note"]) + XCTAssertEqual(result.blocks[0].body, "spaced body") + XCTAssertTrue(result.missing.isEmpty) + } + + func test_bracketedToken_caseInsensitive() { + let r = makeResolver(["Weekly Review": "x"]) + let result = r.resolve(prompt: "@[weekly review]") + XCTAssertEqual(result.blocks.map(\.name), ["Weekly Review"]) + } + + func test_bareToken_stillWorksAlongsideBracket() { + let r = makeResolver(["Foo": "f", "My Note": "m"]) + let result = r.resolve(prompt: "@Foo and @[My Note]") + XCTAssertEqual(Set(result.blocks.map(\.name)), Set(["Foo", "My Note"])) + XCTAssertTrue(result.missing.isEmpty) + } + + func test_bracketedMissing_isReported() { + let r = makeResolver(["Foo": "f"]) + let result = r.resolve(prompt: "@[No Such Note]") + XCTAssertEqual(result.missing, ["No Such Note"]) + XCTAssertTrue(result.blocks.isEmpty) + } + + // MARK: Folder resolution + + /// Builds a resolver where files live at explicit paths and folders are provided. + private func makeFolderResolver( + files: [String: String], // absolute path -> body + folders: [String] // absolute folder paths + ) -> AIContextResolver { + let fileURLs = files.keys.map { URL(fileURLWithPath: $0) } + let folderURLs = folders.map { URL(fileURLWithPath: $0, isDirectory: true) } + return AIContextResolver( + allFiles: fileURLs, + allFolders: folderURLs, + readContents: { url in files[url.path] } + ) + } + + func test_folderToken_concatenatesDirectChildren() { + let r = makeFolderResolver( + files: [ + "/vault/Weekly Summaries/Mon.md": "monday", + "/vault/Weekly Summaries/Tue.md": "tuesday", + "/vault/Other/Skip.md": "nope" + ], + folders: ["/vault/Weekly Summaries", "/vault/Other"] + ) + let result = r.resolve(prompt: "summarize @[Weekly Summaries]") + XCTAssertEqual(result.blocks.map(\.name), ["Weekly Summaries"]) + let body = result.blocks[0].body + XCTAssertTrue(body.contains("monday")) + XCTAssertTrue(body.contains("tuesday")) + XCTAssertFalse(body.contains("nope")) // not a child of this folder + XCTAssertTrue(result.missing.isEmpty) + } + + func test_folderToken_caseInsensitive() { + let r = makeFolderResolver( + files: ["/vault/Weekly Summaries/A.md": "alpha"], + folders: ["/vault/Weekly Summaries"] + ) + let result = r.resolve(prompt: "@[weekly summaries]") + XCTAssertEqual(result.blocks.map(\.name), ["Weekly Summaries"]) + XCTAssertTrue(result.blocks[0].body.contains("alpha")) + } + + func test_emptyFolder_isReportedMissing() { + let r = makeFolderResolver( + files: [:], + folders: ["/vault/Empty"] + ) + let result = r.resolve(prompt: "@Empty") + XCTAssertEqual(result.missing, ["Empty"]) + XCTAssertTrue(result.blocks.isEmpty) + } + + func test_fileWins_overFolderOfSameName() { + let r = makeFolderResolver( + files: [ + "/vault/Notes.md": "the file", + "/vault/Notes/child.md": "the folder child" + ], + folders: ["/vault/Notes"] + ) + let result = r.resolve(prompt: "@Notes") + XCTAssertEqual(result.blocks.map(\.name), ["Notes"]) + XCTAssertEqual(result.blocks[0].body, "the file") // file preferred + } +} diff --git a/macOS/SynapseNotesTests/AIModelTests.swift b/macOS/SynapseNotesTests/AIModelTests.swift new file mode 100644 index 0000000..5fd8d76 --- /dev/null +++ b/macOS/SynapseNotesTests/AIModelTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import Synapse + +final class AIModelTests: XCTestCase { + func test_apiIDs_areExactAnthropicModelStrings() { + XCTAssertEqual(AIModel.haiku.apiID, "claude-haiku-4-5") + XCTAssertEqual(AIModel.sonnet.apiID, "claude-sonnet-4-6") + XCTAssertEqual(AIModel.opus.apiID, "claude-opus-4-8") + } + + func test_displayNames_areHumanReadable() { + XCTAssertEqual(AIModel.haiku.displayName, "Haiku 4.5") + XCTAssertEqual(AIModel.sonnet.displayName, "Sonnet 4.6") + XCTAssertEqual(AIModel.opus.displayName, "Opus 4.8") + } + + func test_initFromAPIID_roundTrips_andDefaultsToSonnetOnUnknown() { + XCTAssertEqual(AIModel(apiID: "claude-opus-4-8"), .opus) + XCTAssertEqual(AIModel(apiID: "garbage"), .sonnet) + } + + func test_defaultModel_isSonnet() { + XCTAssertEqual(AIModel.default, .sonnet) + } +} diff --git a/macOS/SynapseNotesTests/AIRequestBuilderTests.swift b/macOS/SynapseNotesTests/AIRequestBuilderTests.swift new file mode 100644 index 0000000..8578745 --- /dev/null +++ b/macOS/SynapseNotesTests/AIRequestBuilderTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import Synapse + +final class AIRequestBuilderTests: XCTestCase { + private func userText(_ body: [String: Any]) -> String { + let messages = body["messages"] as! [[String: Any]] + return messages.first(where: { $0["role"] as? String == "user" })!["content"] as! String + } + + func test_generate_includesModelStreamMaxTokensAndNote() { + let body = AIRequestBuilder.build( + mode: .generate, + prompt: "write a haiku", + noteText: "# My Note\nSome text", + selection: nil, + context: [], + model: .opus + ) + XCTAssertEqual(body["model"] as? String, "claude-opus-4-8") + XCTAssertEqual(body["stream"] as? Bool, true) + XCTAssertNotNil(body["max_tokens"]) + XCTAssertTrue(userText(body).contains("write a haiku")) + XCTAssertTrue(userText(body).contains("# My Note")) + } + + func test_rewrite_includesSelectedText() { + let body = AIRequestBuilder.build( + mode: .rewrite, + prompt: "make it concise", + noteText: "Full note body", + selection: "The quick brown fox jumped.", + context: [], + model: .sonnet + ) + XCTAssertTrue(userText(body).contains("make it concise")) + XCTAssertTrue(userText(body).contains("The quick brown fox jumped.")) + } + + func test_contextBlocks_areLabeledByName() { + let blocks = [AIContextResolver.Block(name: "Spec", body: "spec contents")] + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: blocks, model: .haiku + ) + let text = userText(body) + XCTAssertTrue(text.contains("Spec")) + XCTAssertTrue(text.contains("spec contents")) + } + + func test_hasSystemPrompt() { + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: [], model: .sonnet + ) + let system = body["system"] as? String + XCTAssertNotNil(system) + XCTAssertFalse(system!.isEmpty) + } + + func test_resultIsJSONSerializable() { + let body = AIRequestBuilder.build( + mode: .rewrite, prompt: "p", noteText: "n", + selection: "s", context: [AIContextResolver.Block(name: "A", body: "b")], + model: .opus + ) + XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: body)) + } +} diff --git a/macOS/SynapseNotesTests/AnthropicClientTests.swift b/macOS/SynapseNotesTests/AnthropicClientTests.swift new file mode 100644 index 0000000..e92e7fc --- /dev/null +++ b/macOS/SynapseNotesTests/AnthropicClientTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import Synapse + +private final class MockSSEURLProtocol: URLProtocol { + static var responseStatus: Int = 200 + static var bodyData: Data = Data() + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, statusCode: MockSSEURLProtocol.responseStatus, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "text/event-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: MockSSEURLProtocol.bodyData) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private final class MockNonHTTPURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + // A plain URLResponse (not HTTPURLResponse) triggers .badResponse. + let response = URLResponse(url: request.url!, mimeType: "text/plain", + expectedContentLength: 0, textEncodingName: nil) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +final class AnthropicClientTests: XCTestCase { + private func makeClient() -> AnthropicClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockSSEURLProtocol.self] + return AnthropicClient(apiKey: "sk-test", urlSession: URLSession(configuration: config)) + } + + private func sse(_ lines: [String]) -> Data { + Data(lines.joined(separator: "\n").appending("\n").utf8) + } + + override func tearDown() { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = Data() + super.tearDown() + } + + func test_streamsTextDeltasInOrder() async throws { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":", world"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: ["model": "claude-sonnet-4-6"]) { + collected += delta + } + XCTAssertEqual(collected, "Hello, world") + } + + func test_ignoresNonDeltaEvents() async throws { + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"message_start","message":{}}"#, + #"data: {"type":"content_block_start","index":0}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"X"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: [:]) { collected += delta } + XCTAssertEqual(collected, "X") + } + + func test_401_throwsInvalidKey() async { + MockSSEURLProtocol.responseStatus = 401 + MockSSEURLProtocol.bodyData = Data() + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .invalidKey) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_500_throwsServerError() async { + MockSSEURLProtocol.responseStatus = 500 + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .server(status: 500)) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_nonHTTPResponse_throwsBadResponse() async { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockNonHTTPURLProtocol.self] + let client = AnthropicClient(apiKey: "sk-test", urlSession: URLSession(configuration: config)) + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .badResponse) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_nonJSONDataLine_isIgnored() async throws { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = sse([ + "data: [DONE]", + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Y"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: [:]) { collected += delta } + XCTAssertEqual(collected, "Y") + } +} diff --git a/macOS/SynapseNotesTests/InlineAIControllerTests.swift b/macOS/SynapseNotesTests/InlineAIControllerTests.swift new file mode 100644 index 0000000..8fe0ee3 --- /dev/null +++ b/macOS/SynapseNotesTests/InlineAIControllerTests.swift @@ -0,0 +1,190 @@ +import XCTest +import AppKit +@testable import Synapse + +final class InlineAIControllerTests: XCTestCase { + private func makeStorage(_ s: String) -> NSTextStorage { + NSTextStorage(string: s) + } + + // MARK: generate mode + + func test_generate_appendDeltas_insertsAtCursor() { + let storage = makeStorage("Hello world") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 6) // between the two spaces + c.appendDelta("brave new") + c.appendDelta(" ") + XCTAssertEqual(storage.string, "Hello brave new world") + } + + func test_generate_cancel_keepsPartialText() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 2) + c.appendDelta("XY") + c.cancel() + XCTAssertEqual(storage.string, "abXY") + } + + // MARK: rewrite mode + + func test_rewrite_appendDeltas_keepOriginalUntilAccept() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + // select "The fox." == range 0..<8 + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + // original still present; new text appended after it + XCTAssertTrue(storage.string.contains("The fox.")) + XCTAssertTrue(storage.string.contains("A fox.")) + } + + func test_rewrite_accept_replacesOriginalWithNew() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.accept() + XCTAssertEqual(storage.string, "A fox.") + } + + func test_rewrite_reject_restoresOriginalOnly() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.reject() + XCTAssertEqual(storage.string, "The fox.") + } + + func test_rewrite_cancelMidStream_thenAccept_usesPartial() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A ") + c.cancel() // streaming stopped; still in pending-accept state + c.accept() + XCTAssertEqual(storage.string, "A ") + } + + func test_rewrite_inMiddle_accept_replacesCorrectSpan() { + // "Hello WORLD!" — select "WORLD" (location 6, length 5), rewrite to "earth" + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") + c.accept() + XCTAssertEqual(storage.string, "Hello earth!") + } + + func test_rewrite_inMiddle_reject_leavesOriginal() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") + c.reject() + XCTAssertEqual(storage.string, "Hello WORLD!") + } + + // MARK: fixed behaviors + + func test_appendDelta_beforeBegin_isNoOp() { + let storage = makeStorage("abc") + let c = InlineAIController() + c.appendDelta("X") + XCTAssertEqual(storage.string, "abc") + } + + func test_accept_inGenerateMode_doesNotMutate() { + let storage = makeStorage("abc") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 3) + c.appendDelta("XY") // "abcXY" + c.accept() // wrong mode for accept → no-op + XCTAssertEqual(storage.string, "abcXY") + } + + func test_beginRewrite_whileActive_isIgnored() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") // "Hello WORLDearth!", original (6,5) + // A second begin must be ignored so the first session's ranges stay intact. + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 5)) + c.accept() // resolves the FIRST session + XCTAssertEqual(storage.string, "Hello earth!") + } + + func test_appendDelta_multibyteEmoji_tracksUTF16Length() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 2)) + c.appendDelta("👩‍🚀") // family/ZWJ emoji: NSString length 5 + c.accept() // deletes original "ab", leaves the emoji + XCTAssertEqual(storage.string, "👩‍🚀") + } + + func test_reject_withNoDeltas_isSafeNoOpOnNewText() { + let storage = makeStorage("keep me") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 4)) // "keep" + c.reject() // zero deltas appended; deleting empty newRange is safe + XCTAssertEqual(storage.string, "keep me") + } + + // MARK: discardOutput (Retry support) + + func test_discardOutput_generate_removesInsertedTextAndIdles() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 2) + c.appendDelta("XYZ") // "abXYZ" + c.discardOutput() + XCTAssertEqual(storage.string, "ab") + XCTAssertEqual(c.mode, .idle) + } + + func test_discardOutput_rewrite_removesNewTextKeepsOriginal() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") // "The fox.A fox." + c.discardOutput() + XCTAssertEqual(storage.string, "The fox.") + XCTAssertEqual(c.mode, .idle) + } + + func test_retryFlow_generate_replacesRatherThanAppends() { + // Simulates Retry: discard the first generation, then re-begin and stream again. + // Regression test for the bug where Retry appended onto the previous output. + let storage = makeStorage("Start: ") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 7) + c.appendDelta("first attempt") // "Start: first attempt" + c.discardOutput() // back to "Start: " + c.beginGenerate(in: storage, at: 7) + c.appendDelta("second attempt") + XCTAssertEqual(storage.string, "Start: second attempt") + } + + func test_retryFlow_rewrite_replacesRatherThanAppends() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) // "WORLD" + c.appendDelta("earth") // "Hello WORLDearth!" + c.discardOutput() // back to "Hello WORLD!" + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("mars") + c.accept() + XCTAssertEqual(storage.string, "Hello mars!") + } + + func test_discardOutput_whenIdle_isNoOp() { + let storage = makeStorage("untouched") + let c = InlineAIController() + c.discardOutput() + XCTAssertEqual(storage.string, "untouched") + XCTAssertEqual(c.mode, .idle) + } +} diff --git a/macOS/SynapseNotesTests/KeychainStoreTests.swift b/macOS/SynapseNotesTests/KeychainStoreTests.swift new file mode 100644 index 0000000..458e3d2 --- /dev/null +++ b/macOS/SynapseNotesTests/KeychainStoreTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import Synapse + +/// Exercises the `SecretStore` contract against the in-memory implementation. We do NOT +/// hit the real system keychain here: from an ad-hoc-signed test host that triggers a +/// login-password prompt (file-based keychain) or a silent entitlement failure +/// (data-protection keychain). `KeychainStore` is a thin SecItem wrapper over the same +/// contract; the behavior under test is the get/set/delete semantics. +final class KeychainStoreTests: XCTestCase { + var store: SecretStore! + + override func setUp() { + super.setUp() + store = InMemorySecretStore() + } + + override func tearDown() { + store = nil + super.tearDown() + } + + func test_getBeforeSet_returnsNil() { + XCTAssertNil(store.get()) + } + + func test_setThenGet_roundTrips() { + store.set("sk-ant-secret") + XCTAssertEqual(store.get(), "sk-ant-secret") + } + + func test_setOverwrites_existingValue() { + store.set("first") + store.set("second") + XCTAssertEqual(store.get(), "second") + } + + func test_setEmptyString_deletesTheItem() { + store.set("value") + store.set("") + XCTAssertNil(store.get()) + } + + func test_setWhitespaceOnly_deletesTheItem() { + store.set("value") + store.set(" \n ") + XCTAssertNil(store.get()) + } + + func test_setTrimsWhitespace() { + store.set(" sk-ant-padded ") + XCTAssertEqual(store.get(), "sk-ant-padded") + } + + func test_delete_removesValue() { + store.set("value") + store.delete() + XCTAssertNil(store.get()) + } + + func test_inMemoryStore_seedsInitialValue() { + let seeded = InMemorySecretStore("preset") + XCTAssertEqual(seeded.get(), "preset") + } +} diff --git a/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift new file mode 100644 index 0000000..f4e2b1b --- /dev/null +++ b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Synapse + +final class SettingsManagerAIModelTests: XCTestCase { + private var tempDir: URL! + private var globalPath: String! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + globalPath = tempDir.appendingPathComponent("global.yml").path + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + func test_default_isSonnetAPIID() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(mgr.aiDefaultModel, "claude-sonnet-4-6") + } + + func test_aiDefaultModel_persistsAcrossReload() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + mgr.aiDefaultModel = "claude-opus-4-8" + let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(reloaded.aiDefaultModel, "claude-opus-4-8") + } + + func test_showAISparkle_defaultsToTrue() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertTrue(mgr.showAISparkle) + } + + func test_showAISparkle_persistsFalseAcrossReload() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + mgr.showAISparkle = false + let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertFalse(reloaded.showAISparkle) + } +} diff --git a/marketing-site/docs/index.md b/marketing-site/docs/index.md index 625c02e..b9817f6 100644 --- a/marketing-site/docs/index.md +++ b/marketing-site/docs/index.md @@ -181,6 +181,11 @@ Synapse Notes relies heavily on keyboard shortcuts to help you navigate and edit | Split Horizontal | `CMD + SHIFT + D` | | Switch Panes | `CMD + OPT + Arrows` | +### AI +| Action | Shortcut | +| --- | --- | +| Open Inline AI Editing | `OPT + J` (or click the ✨ at the cursor) | + ### Other | Action | Shortcut | | --- | --- | diff --git a/marketing-site/docs/markdown.md b/marketing-site/docs/markdown.md index ce027be..0e4e0df 100644 --- a/marketing-site/docs/markdown.md +++ b/marketing-site/docs/markdown.md @@ -388,6 +388,7 @@ While editing: - **⌘F** - Find in note - **⌘G** - Find next - **⇧⌘G** - Find previous +- **⌥J** - Open inline AI editing at the cursor (or click the ✨) - **/command** - Slash commands expand inline at line start or after a space ### Navigation Tips